diff --git a/src/host/local-hm.test.ts b/src/host/local-hm.test.ts index 946f3ad..bf65ddd 100644 --- a/src/host/local-hm.test.ts +++ b/src/host/local-hm.test.ts @@ -109,16 +109,19 @@ describe('localHmDataDir', () => { }); describe('parseDriftError', () => { - it('returns null when the marker is absent', () => { + it('returns null when no drift marker is present', () => { expect(parseDriftError('container started\nlogging stuff')).toBeNull(); }); - it('returns null when the marker is present but no wallet= match', () => { - // Marker without a wallet= field — can't extract the real pubkey. + it('returns null when the pubkey marker is present but no wallet= match', () => { expect(parseDriftError('Error: MANAGER_PUBKEY mismatch: env="abc"')).toBeNull(); }); - it('extracts the real pubkey from the drift-guard message', () => { + it('returns null when the direct-address marker is present but no wallet= match', () => { + expect(parseDriftError('Error: MANAGER_DIRECT_ADDRESS mismatch: env="xyz"')).toBeNull(); + }); + + it('extracts the real pubkey from the pubkey-drift message; direct address is null', () => { const msg = 'Error: MANAGER_PUBKEY mismatch: env="placeholderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ' + 'wallet="02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc". ' + @@ -126,8 +129,23 @@ describe('parseDriftError', () => { const got = parseDriftError(msg); expect(got).not.toBeNull(); expect(got?.managerPubkey).toBe('02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc'); + // Direct address is NOT inferred from the pubkey — sphere wallets + // have a custom direct-address encoding that's not just the pubkey + // prefixed with DIRECT://. The 3-shot bootstrap reveals it on the + // next iteration. + expect(got?.managerDirectAddress).toBeNull(); + }); + + it('extracts the real direct address from the direct-address drift message; pubkey is null', () => { + const msg = + 'Fatal startup error: ConfigError: MANAGER_DIRECT_ADDRESS mismatch: env="DIRECT://03d934...", ' + + 'wallet="DIRECT://00006695dc5d4fb02706a69b587d3ccf06ed6000af6df053ccff7665a6e1c46cbec4e69282f0". ' + + 'Either fix the env var or point at the correct wallet.'; + const got = parseDriftError(msg); + expect(got).not.toBeNull(); + expect(got?.managerPubkey).toBeNull(); expect(got?.managerDirectAddress).toBe( - 'DIRECT://02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc', + 'DIRECT://00006695dc5d4fb02706a69b587d3ccf06ed6000af6df053ccff7665a6e1c46cbec4e69282f0', ); }); @@ -138,6 +156,16 @@ describe('parseDriftError', () => { const got = parseDriftError(msg); expect(got?.managerPubkey).toBe('02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc'); }); + + it('honours the most recent occurrence when multiple drift errors are logged', () => { + // Container restart loop would replay old errors in the log buffer; + // we want the freshest values. + const msg = + 'MANAGER_PUBKEY mismatch: env="x", wallet="0200000000000000000000000000000000000000000000000000000000000000aa".\n' + + 'MANAGER_PUBKEY mismatch: env="y", wallet="02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc".'; + const got = parseDriftError(msg); + expect(got?.managerPubkey).toBe('02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc'); + }); }); describe('buildHmEnv', () => { @@ -147,6 +175,7 @@ describe('buildHmEnv', () => { hostId: 'u-test', managerPubkey: '0279...', managerDirectAddress: 'DIRECT://0279...', + tenantsHostDir: '/home/u/.sphere-cli/local-hm/02ab/tenants', healthPort: 9501, }); expect(env).toMatchObject({ @@ -166,6 +195,7 @@ describe('buildHmEnv', () => { hostId: 'u-test', managerPubkey: '0279...', managerDirectAddress: 'DIRECT://0279...', + tenantsHostDir: '/tmp/tenants', network: 'mainnet', healthPort: 9401, }); @@ -178,6 +208,7 @@ describe('buildHmEnv', () => { hostId: 'u-test', managerPubkey: '0279...', managerDirectAddress: 'DIRECT://0279...', + tenantsHostDir: '/tmp/tenants', healthPort: 9401, }); // Pinned by Dockerfile.host-manager — not configurable on the HM side. @@ -185,6 +216,24 @@ describe('buildHmEnv', () => { expect(env['SPHERE_MANAGER_DATA_DIR']).toBe('/app/sphere-manager'); expect(env['PERSISTENCE_PATH']).toBe('/app/state/state.json'); }); + + it('sets TENANTS_DIR to the supplied host path (identical-path bind mount)', () => { + // Identical-path bind mount: TENANTS_DIR must be the SAME host + // path that's bind-mounted into the HM container. Otherwise the + // HM's docker-in-docker bind mounts for tenants resolve a + // non-existent path on the host, docker creates an empty + // root-owned dir there, and the tenant hits EACCES. + const tenantsHostDir = '/home/dev/.sphere-cli/local-hm/abc123/tenants'; + const env = buildHmEnv({ + controllerPubkey: '02ab', + hostId: 'u-test', + managerPubkey: '0279...', + managerDirectAddress: 'DIRECT://0279...', + tenantsHostDir, + healthPort: 9401, + }); + expect(env['TENANTS_DIR']).toBe(tenantsHostDir); + }); }); describe('ensureTemplatesFile', () => { diff --git a/src/host/local-hm.ts b/src/host/local-hm.ts index f26d427..99dad2e 100644 --- a/src/host/local-hm.ts +++ b/src/host/local-hm.ts @@ -60,15 +60,37 @@ export const DEFAULT_HM_IMAGE = 'ghcr.io/unicitynetwork/agentic-hosting/host-manager:latest'; /** - * Where the HM expects its wallet to live inside the container. Pinned - * by the agentic-hosting Dockerfile + docker-compose volume mapping — - * not configurable on the HM side. + * Where the HM expects its wallet + state + templates inside the + * container. Pinned by the agentic-hosting Dockerfile. */ const HM_WALLET_PATH_IN_CONTAINER = '/app/sphere-manager'; const HM_STATE_PATH_IN_CONTAINER = '/app/state'; -const HM_TENANTS_PATH_IN_CONTAINER = '/var/lib/agentic-hosting/tenants'; const HM_TEMPLATES_PATH_IN_CONTAINER = '/app/config/templates.json'; +/** + * Tenants directory: identical-path bind mount strategy. + * + * The HM uses `TENANTS_DIR` as the base path for tenant instance dirs + * (wallet, tokens, escrow). When the HM spawns a tenant, it tells the + * docker daemon (via the bind-mounted socket) to bind-mount + * `${TENANTS_DIR}//wallet` into `/data/wallet` of the tenant. + * + * The trap: the HM runs in a container that shares the docker daemon + * socket with the host. When the HM asks docker to bind-mount a path + * that exists only inside the HM container (e.g. a container-only + * `/var/lib/agentic-hosting/tenants/X`, or a named volume that the host + * doesn't see at the same path), the daemon resolves the source on the + * HOST's filesystem, doesn't find it, and silently creates an empty + * root:root-owned dir there. The tenant's `node` user (uid 1000) then + * hits EACCES writing its wallet. + * + * Fix: mount the host tenants dir at the SAME host path inside the HM. + * The HM then sees an identical path, the docker daemon finds the same + * directory on the host, and the bind chain resolves correctly with + * the right ownership. This is what the agentic-hosting production + * docker-compose.override.yml does (see its comment block). + */ + /** * docker socket path (`/var/run/docker.sock`). Mounted read-write into * the HM so it can spawn tenant containers via dockerode. The local user @@ -95,12 +117,20 @@ const DEFAULT_READY_TIMEOUT_MS = 90_000; const READY_LOG_MARKER = 'sphere_initialized'; /** - * Failure marker — the drift guard's exception message includes literal - * "MANAGER_PUBKEY mismatch". A second-boot failure that's NOT this - * exact shape (e.g., image entrypoint crashed) needs to surface to the - * operator, not silently retry. + * Failure markers — the HM's drift guards emit two distinct error + * shapes depending on which check fails. We need both because the + * bootstrap dance reveals each value on a different boot: + * + * - First boot (placeholder pubkey + placeholder DA): the pubkey + * check fails first → DRIFT_GUARD_MARKER fires + * - Second boot (real pubkey + pubkey-derived DA): the pubkey check + * passes, the DA check fails → DIRECT_ADDRESS_DRIFT_MARKER fires + * + * A second-boot failure with neither marker (e.g., entrypoint crashed) + * surfaces to the operator as a timeout, not a silent retry. */ const DRIFT_GUARD_MARKER = 'MANAGER_PUBKEY mismatch'; +const DIRECT_ADDRESS_DRIFT_MARKER = 'MANAGER_DIRECT_ADDRESS mismatch'; /** * The placeholder values we ship to the HM on first boot. They MUST be @@ -437,45 +467,71 @@ export function detectDockerGid(): number { // ============================================================================= /** - * Match the HM's drift-guard error message and extract the wallet's - * real pubkey + direct address. The agentic_hosting source - * (host-manager/main.ts:502-507) embeds both in the ConfigError - * message; we match defensively in case logger formatting wraps lines. + * Match the HM's drift-guard error messages and extract whichever real + * wallet values the error message reveals. The agentic_hosting source + * (host-manager/main.ts:502-514) does TWO sequential checks against + * the loaded wallet identity: * - * Returns null when the marker isn't present yet (poll again). + * 1. `pubkeysEqual(loadedPubkey, config.manager_pubkey)` — if false, + * throws `MANAGER_PUBKEY mismatch: env="…", wallet=""` + * and exits BEFORE the next check. + * 2. `loadedDirectAddress !== config.manager_direct_address` — if + * true, throws `MANAGER_DIRECT_ADDRESS mismatch: env="…", wallet=""`. + * + * Because the checks are sequential, a single boot can reveal AT MOST + * one of the two real values. The bootstrap caller has to do multiple + * boots: + * - Boot 1 (placeholder both) → pubkey mismatch surfaces (boot 2's input is real pubkey) + * - Boot 2 (real pubkey, derived DA) → direct-address mismatch surfaces (boot 3's input is real both) + * - Boot 3 (real both) → succeeds + * + * A sphere wallet's directAddress is NOT `DIRECT://` — + * sphere-sdk computes a custom 36-byte address with a `0000…` prefix. + * That's why a 2-shot bootstrap (the old design) fails on boot 2 with + * a direct-address mismatch. + * + * Returns null when no drift marker present yet (poll again). + * Returns the field(s) the error reveals; the missing field is `null` + * and the caller carries forward its current best guess (placeholder + * on boot 1, pubkey-derived on boot 2). */ -export function parseDriftError(logs: string): { managerPubkey: string; managerDirectAddress: string } | null { - if (!logs.includes(DRIFT_GUARD_MARKER)) return null; - const pubkeyMatch = logs.match(/wallet=["']([0-9a-fA-F]{66,130})["']/); - // The direct address mismatch line is emitted SEPARATELY from the - // pubkey mismatch line — but the pubkey-mismatch line always fires - // first and aborts before the direct-address check runs. So we have - // to derive the direct address ourselves from the loaded wallet. - // The HM emits the loaded direct address in the "sphere_initialized" - // line — but on a drift-failed boot that line never runs. The only - // signal we have is the pubkey. - // - // Workaround: derive DIRECT:// ourselves. agentic_hosting's - // main.ts:495 does the same fallback: `loadedDirectAddress = ... ?? \`DIRECT://${loadedPubkey}\``. - // We assume the wallet didn't register a custom direct address — - // first-boot wallets never do, since we don't pass nametag in the - // bootstrap env. - if (!pubkeyMatch || !pubkeyMatch[1]) return null; - const managerPubkey = pubkeyMatch[1].toLowerCase(); +export function parseDriftError(logs: string): { + managerPubkey: string | null; + managerDirectAddress: string | null; +} | null { + if (!logs.includes(DRIFT_GUARD_MARKER) && !logs.includes(DIRECT_ADDRESS_DRIFT_MARKER)) { + return null; + } + // The pubkey mismatch line uses `wallet="<66-130 hex>"` (compressed/ + // x-only/uncompressed pubkey hex). Match the most recent occurrence — + // logs may accumulate multiple lines across restarts. + const pubkeyMatches = Array.from( + logs.matchAll(/MANAGER_PUBKEY mismatch:[^\n]*wallet=["']([0-9a-fA-F]{66,130})["']/g), + ); + // The direct-address mismatch line uses `wallet="DIRECT://"`. + // Match its most recent occurrence too. + const daMatches = Array.from( + logs.matchAll(/MANAGER_DIRECT_ADDRESS mismatch:[^\n]*wallet=["'](DIRECT:\/\/[0-9a-fA-F]+)["']/g), + ); + const pubkeyMatch = pubkeyMatches[pubkeyMatches.length - 1]; + const daMatch = daMatches[daMatches.length - 1]; + if (!pubkeyMatch && !daMatch) return null; return { - managerPubkey, - managerDirectAddress: `DIRECT://${managerPubkey}`, + managerPubkey: pubkeyMatch ? pubkeyMatch[1].toLowerCase() : null, + managerDirectAddress: daMatch ? daMatch[1] : null, }; } /** - * Poll a container's logs until either the success marker or the - * drift-guard marker appears. Returns whichever fired first, or null - * on timeout. + * Poll a container's logs until either the success marker or any of + * the drift markers appears. Returns whichever fired first, or + * timeout. The drift result reports whichever real fields the HM's + * error message revealed; the caller is responsible for carrying + * forward whatever it hasn't learnt yet. */ type WaitForLogResult = | { kind: 'ready' } - | { kind: 'drift'; managerPubkey: string; managerDirectAddress: string } + | { kind: 'drift'; managerPubkey: string | null; managerDirectAddress: string | null } | { kind: 'timeout'; lastLogs: string }; async function waitForBootSignal(containerName: string, timeoutMs: number): Promise { @@ -513,6 +569,15 @@ export function buildHmEnv(opts: { readonly hostId: string; readonly managerPubkey: string; readonly managerDirectAddress: string; + /** + * HOST-side absolute path to the tenants directory. This same path + * is also bind-mounted into the HM container at the same location, + * so docker-in-docker bind mounts the HM issues against + * `${tenantsHostDir}//wallet` resolve correctly on the + * host. See the comment block above this file's HM constants for + * the docker-daemon path-resolution rationale. + */ + readonly tenantsHostDir: string; readonly network?: string; readonly healthPort: number; }): Record { @@ -523,7 +588,7 @@ export function buildHmEnv(opts: { MANAGER_DIRECT_ADDRESS: opts.managerDirectAddress, SPHERE_MANAGER_DATA_DIR: HM_WALLET_PATH_IN_CONTAINER, TEMPLATES_PATH: HM_TEMPLATES_PATH_IN_CONTAINER, - TENANTS_DIR: HM_TENANTS_PATH_IN_CONTAINER, + TENANTS_DIR: opts.tenantsHostDir, PERSISTENCE_PATH: path.posix.join(HM_STATE_PATH_IN_CONTAINER, 'state.json'), DOCKER_SOCKET: DOCKER_SOCKET, UNICITY_NETWORK: opts.network ?? 'testnet', @@ -542,15 +607,19 @@ export function buildHmEnv(opts: { * escrow-service so the local HM can spawn either without needing * the operator to ship their own templates registry. * - * Image versions match what the trader-roundtrip soak script - * expects today (`trader:v0.1`, `escrow:v0.3`). Bumps land via - * agentic_hosting issue #26 (image rebuild) → CLI follow-up PR. + * Image versions: + * - `trader:v0.2` — built from sphere-sdk@550114c with the four + * rotations the v0.1 image predated (#456 / #457 / #464 / #447). + * Cut as `vrogojin/trader-service` release v0.2 (2026-06-10). + * - `escrow:v0.3` — current production escrow image. + * + * Bumps land via agentic_hosting follow-ups + CLI updates. */ export const DEFAULT_TEMPLATES: { templates: ReadonlyArray } = { templates: [ { template_id: 'trader-agent', - image: 'ghcr.io/vrogojin/agentic-hosting/trader:v0.1', + image: 'ghcr.io/vrogojin/agentic-hosting/trader:v0.2', entrypoint: ['node', '/app/dist/acp-adapter/main.js'], env_defaults: { LOG_LEVEL: 'info', @@ -668,9 +737,16 @@ export async function ensureLocalHM(config: LocalHmConfig): Promise 0 && realManagerPubkey !== null && realManagerDirectAddress !== null; + const timeoutMs = expectingReady ? DEFAULT_READY_TIMEOUT_MS : BOOTSTRAP_LOG_TIMEOUT_MS; + + const result = await waitForBootSignal(containerName, timeoutMs); + + if (result.kind === 'timeout') { + lastLogsForError = result.lastLogs; await dockerRm(containerName); throw new Error( - `Local HM second-boot did not reach sphere_initialized within ${DEFAULT_READY_TIMEOUT_MS}ms. Tail of logs:\n${ready.lastLogs.slice(-2000)}`, + `Local HM boot ${bootNum + 1}/${maxBoots} timed out after ${timeoutMs}ms. Tail of logs:\n${lastLogsForError.slice(-2000)}`, ); } + + if (result.kind === 'ready') { + // Success. If we didn't learn both values from drift errors + // (e.g., boot 1 succeeded because the wallet directory's pubkey + // happened to match the placeholder — rare but possible on a + // reuse), pull them from the success log. + if (realManagerPubkey === null || realManagerDirectAddress === null) { + const liveInfo = parseManagerIdentityFromLogs(await readContainerLogs(containerName, 1000)); + if (!liveInfo) { + await dockerRm(containerName); + throw new Error( + 'Local HM boot succeeded but sphere-cli could not parse the manager identity from logs. ' + + 'Stop the container manually and re-run `sphere host local-spawn`.', + ); + } + realManagerPubkey = realManagerPubkey ?? liveInfo.managerPubkey; + realManagerDirectAddress = realManagerDirectAddress ?? liveInfo.managerDirectAddress; + } + break; + } + + // result.kind === 'drift' — extract whichever real value the error + // revealed and prep for the next boot. + if (result.managerPubkey) { + realManagerPubkey = result.managerPubkey; + currentPubkey = result.managerPubkey; + } + if (result.managerDirectAddress) { + realManagerDirectAddress = result.managerDirectAddress; + currentDirectAddress = result.managerDirectAddress; + } + + // The HM exits after throwing — we have to docker rm before the + // next attempt or docker run will conflict on the container name. + await dockerRm(containerName); + + // Loop iterates and tries again with the updated env values. + } + + if (bootNum === maxBoots) { + throw new Error( + `Local HM bootstrap exhausted ${maxBoots} attempts without reaching sphere_initialized. ` + + `Last known state: pubkey=${realManagerPubkey ?? '(unknown)'}, ` + + `directAddress=${realManagerDirectAddress ?? '(unknown)'}. ` + + `Inspect docker logs ${containerName} for the underlying ConfigError.`, + ); + } + + if (realManagerPubkey === null || realManagerDirectAddress === null) { + throw new Error( + 'Local HM bootstrap completed but sphere-cli was unable to determine the manager identity. ' + + 'This is a sphere-cli bug; please file a report with the docker logs.', + ); } const meta: LocalHmMetadata = { diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 1f17e1d..ff26c4b 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -723,6 +723,12 @@ async function getSphere(options?: { autoGenerate?: boolean; mnemonic?: string; const result = await Sphere.init({ ...initProviders, + // Phase 6 P2 fork — pass network through so ensureTokenEngine picks + // up NETWORKS[network].trustBaseUrl + aggregatorApiKey and constructs + // the v2 SphereTokenEngine. Without this Sphere.init defaults to + // 'mainnet' and testnet2 wallets fail to mint nametags with + // "Token engine not available". + network: config.network, autoGenerate: options?.autoGenerate, mnemonic: options?.mnemonic, nametag: options?.nametag, @@ -2746,6 +2752,8 @@ async function main(): Promise { const { sphere: legacySphere } = await Sphere.init({ ...legacyProviders, + // Phase 6 P2 fork — network for v2 token engine construction. + network: config.network, transport: createNoopTransport(), autoGenerate: false, }); diff --git a/src/trader/acp-transport.ts b/src/trader/acp-transport.ts index 40a0135..d65215a 100644 --- a/src/trader/acp-transport.ts +++ b/src/trader/acp-transport.ts @@ -10,7 +10,7 @@ * Mirror of trader-service/src/cli/dm-transport.ts. */ -import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { DirectMessage, SendMessageOptions } from '@unicitylabs/sphere-sdk'; import { createAcpMessage, @@ -30,7 +30,11 @@ export type { TimeoutError, TransportError }; export { MIN_TIMEOUT_MS }; export interface SphereComms { - sendDM(recipient: string, content: string): Promise<{ recipientPubkey: string }>; + sendDM( + recipient: string, + content: string, + options?: SendMessageOptions, + ): Promise<{ recipientPubkey: string }>; onDirectMessage(handler: (message: DirectMessage) => void): () => void; } @@ -165,7 +169,12 @@ class AcpDmTransportImpl implements AcpDmTransport { return; } - this.comms.sendDM(this.tenantAddress, payload).then((sent) => { + // sphere-sdk#559 / PR #558 — ACP RPC is short-lived (the trader + // CLI's `sphere trader ` exits as soon as the result + // lands). Skip the self-wrap so we don't leave a copy of every + // outgoing ACP command in the relay's `#p:controller` index for + // the next short-lived CLI process to pull down on subscribe. + this.comms.sendDM(this.tenantAddress, payload, { selfWrap: false }).then((sent) => { if (!this.resolvedPubkey) { this.resolvedPubkey = normalizeKey(sent.recipientPubkey); // Drain pre-resolution buffer. diff --git a/src/trader/spawn.test.ts b/src/trader/spawn.test.ts index 9425c51..6d66e42 100644 --- a/src/trader/spawn.test.ts +++ b/src/trader/spawn.test.ts @@ -44,34 +44,19 @@ describe('deriveTenantName', () => { }); describe('buildTraderEnv', () => { - it('always sets UNICITY_CONTROLLER_PUBKEY', () => { + it('produces an empty env when only controllerPubkey is set (controller pubkey is HM-injected, not user-supplied)', () => { + // The HM auto-injects UNICITY_CONTROLLER_PUBKEY from the request's + // sender pubkey. The wrapper deliberately does NOT include it in + // user-supplied env — the HM would reject UNICITY_* prefix anyway. const env = buildTraderEnv({ controllerPubkey: '0398' }); - expect(env['UNICITY_CONTROLLER_PUBKEY']).toBe('0398'); + expect(env).toEqual({}); }); - it('omits TRADER_SCAN_INTERVAL_MS when not set', () => { - const env = buildTraderEnv({ controllerPubkey: '0398' }); - expect(env).not.toHaveProperty('TRADER_SCAN_INTERVAL_MS'); - }); - - it('passes scanIntervalMs through as string', () => { + it('passes scanIntervalMs through as TRADER_SCAN_INTERVAL_MS', () => { const env = buildTraderEnv({ controllerPubkey: '0398', scanIntervalMs: 15000 }); expect(env['TRADER_SCAN_INTERVAL_MS']).toBe('15000'); }); - it('joins trustedEscrows with commas', () => { - const env = buildTraderEnv({ - controllerPubkey: '0398', - trustedEscrows: ['@escrow-test-02', '@my-local-escrow'], - }); - expect(env['UNICITY_TRUSTED_ESCROWS']).toBe('@escrow-test-02,@my-local-escrow'); - }); - - it('omits UNICITY_TRUSTED_ESCROWS when list is empty', () => { - const env = buildTraderEnv({ controllerPubkey: '0398', trustedEscrows: [] }); - expect(env).not.toHaveProperty('UNICITY_TRUSTED_ESCROWS'); - }); - it('pairs TRADER_TEST_FUND with TRADER_FAULT_INJECTION_ALLOWED=1', () => { const env = buildTraderEnv({ controllerPubkey: '0398', @@ -87,22 +72,19 @@ describe('buildTraderEnv', () => { expect(env).not.toHaveProperty('TRADER_FAULT_INJECTION_ALLOWED'); }); - it('passes through the network override', () => { - const env = buildTraderEnv({ controllerPubkey: '0398', network: 'dev' }); - expect(env['UNICITY_NETWORK']).toBe('dev'); - }); - - it('does NOT set ACP boot envelope keys (HM injects those)', () => { - // UNICITY_MANAGER_PUBKEY / UNICITY_BOOT_TOKEN / UNICITY_INSTANCE_ID / - // UNICITY_INSTANCE_NAME / UNICITY_TEMPLATE_ID are injected by the HM - // when it spawns the tenant container. The wrapper layer must not - // override them — see spawn.ts comment block. - const env = buildTraderEnv({ controllerPubkey: '0398' }); - expect(env).not.toHaveProperty('UNICITY_MANAGER_PUBKEY'); - expect(env).not.toHaveProperty('UNICITY_BOOT_TOKEN'); - expect(env).not.toHaveProperty('UNICITY_INSTANCE_ID'); - expect(env).not.toHaveProperty('UNICITY_INSTANCE_NAME'); - expect(env).not.toHaveProperty('UNICITY_TEMPLATE_ID'); + it('does NOT emit any UNICITY_* key — HM blocks the entire prefix in user env', () => { + // Defense-in-depth: even if a future opt sets a UNICITY_* key, the + // HM (agentic_hosting/src/host-manager/manager.ts:113) rejects the + // entire hm.spawn payload with `Forbidden env var prefix`. The + // wrapper must keep this surface clean. + const env = buildTraderEnv({ + controllerPubkey: '0398', + scanIntervalMs: 30000, + testFund: 'aa:1', + }); + for (const key of Object.keys(env)) { + expect(key.toUpperCase().startsWith('UNICITY_'), `key ${key} has forbidden UNICITY_ prefix`).toBe(false); + } }); }); diff --git a/src/trader/spawn.ts b/src/trader/spawn.ts index a7a09d5..2e50909 100644 --- a/src/trader/spawn.ts +++ b/src/trader/spawn.ts @@ -29,6 +29,7 @@ import { initSphere } from '../host/sphere-init.js'; import { createDmTransport, type DmTransport } from '../transport/dm-transport.js'; import { createHmcpRequest, type HmcpRequest, type HmcpResponse } from '../transport/hmcp-types.js'; import { TimeoutError, TransportError } from '../transport/errors.js'; +import { createAcpDmTransport } from './acp-transport.js'; // ============================================================================= // Types @@ -152,34 +153,38 @@ export function deriveTenantName( * DMs signed by our wallet * - `UNICITY_NETWORK` / `UNICITY_RELAYS` (relays inherited from HM's * template defaults; we don't override unless asked) - * - `UNICITY_TRUSTED_ESCROWS` / `TRADER_SCAN_INTERVAL_MS` + * - `TRADER_SCAN_INTERVAL_MS` (non-UNICITY_ prefix, so the HM accepts it) * - Test-fund + fault-injection allow-flag for testnet self-mint * - * Critically: we do NOT synthesize the ACP boot envelope - * (UNICITY_MANAGER_PUBKEY, UNICITY_BOOT_TOKEN, UNICITY_INSTANCE_ID, - * UNICITY_INSTANCE_NAME, UNICITY_TEMPLATE_ID) — the HM injects those - * itself when it spawns the tenant container, because parseTenantConfig - * reads them from the env the HM passes (`docker run -e ...`). + * Critically: we do NOT pass ANY `UNICITY_*` env vars via `hm.spawn`. + * The HM blocks the entire `UNICITY_*` prefix in user-supplied env + * (`shared/forbidden-env.ts`) to prevent boot-envelope spoofing, and + * injects the boot envelope itself: `UNICITY_MANAGER_PUBKEY`, + * `UNICITY_MANAGER_DIRECT_ADDRESS`, `UNICITY_BOOT_TOKEN`, + * `UNICITY_INSTANCE_ID`, `UNICITY_INSTANCE_NAME`, `UNICITY_TEMPLATE_ID`, + * `UNICITY_NETWORK`, `UNICITY_DATA_DIR`, `UNICITY_TOKENS_DIR`, AND + * `UNICITY_CONTROLLER_PUBKEY` (derived from the request's sender pubkey). + * See `agentic_hosting/src/host-manager/manager.ts:1021-1033`. + * + * Trusted escrows are NOT passed via env (they'd need `UNICITY_TRUSTED_ESCROWS` + * which the HM blocks). Instead, after spawn we chain a `SET_STRATEGY` + * ACP command — see `spawnTrader()` below. * * Exported for unit tests. */ export function buildTraderEnv(opts: { readonly controllerPubkey: string; - readonly trustedEscrows?: ReadonlyArray; readonly scanIntervalMs?: number; readonly testFund?: string; - readonly network?: string; }): Record { - const env: Record = { - UNICITY_CONTROLLER_PUBKEY: opts.controllerPubkey, - }; - if (opts.network) env['UNICITY_NETWORK'] = opts.network; + // controllerPubkey accepted in the signature for back-compat / call-site + // consistency but NOT emitted — the HM derives it from the request's + // sender pubkey on its own. + void opts.controllerPubkey; + const env: Record = {}; if (opts.scanIntervalMs !== undefined) { env['TRADER_SCAN_INTERVAL_MS'] = String(opts.scanIntervalMs); } - if (opts.trustedEscrows && opts.trustedEscrows.length > 0) { - env['UNICITY_TRUSTED_ESCROWS'] = opts.trustedEscrows.join(','); - } if (opts.testFund && opts.testFund.trim()) { // The trader's production guard requires TRADER_FAULT_INJECTION_ALLOWED=1 // alongside TRADER_TEST_FUND to actually self-mint. Tie them @@ -239,18 +244,38 @@ export async function spawnTrader(opts: TraderSpawnOptions): Promise` nametag. + // Operator workflows (the trader-roundtrip soak, the demo playbook, + // typical scripted callers) expect to address the tenant under the + // human-readable instance name they chose, so we default to that. + // A future flag (`--tenant-nametag`) could override. const result = await spawnOrAdoptTenant(sphere, hmMeta, { instance_name: instanceName, template_id: DEFAULT_TEMPLATE_ID, + nametag: instanceName, env: traderEnv, }, opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + // ── 3. Configure trusted escrows via post-spawn SET_STRATEGY ────── + // UNICITY_TRUSTED_ESCROWS can't ride in the hm.spawn env (UNICITY_* + // prefix is HM-blocked to prevent boot-envelope spoofing — see + // agentic_hosting/src/shared/forbidden-env.ts). The trader exposes + // a SET_STRATEGY ACP command that overrides DEFAULT_STRATEGY at + // runtime. We use it here so `--trusted-escrows` works end-to-end + // through the wrapper. + if (opts.trustedEscrows && opts.trustedEscrows.length > 0) { + await applyTrustedEscrows(sphere, result.tenant_direct_address, opts.trustedEscrows); + } + return { ...result, hm_container: hmMeta.containerName, @@ -332,7 +357,7 @@ interface AdoptedTenant { async function spawnOrAdoptTenant( sphere: Sphere, hmMeta: LocalHmMetadata, - payload: { instance_name: string; template_id: string; env: Record }, + payload: { instance_name: string; template_id: string; nametag?: string; env: Record }, readyTimeoutMs: number, ): Promise { const transport = createDmTransport(sphere.communications, { @@ -539,6 +564,50 @@ export function isLiveState(state: string): boolean { return s === 'CREATED' || s === 'BOOTING' || s === 'RUNNING'; } +/** + * Set the freshly-spawned trader tenant's `trusted_escrows` strategy via + * a SET_STRATEGY ACP command. Needed because UNICITY_TRUSTED_ESCROWS + * can't ride in `hm.spawn` env (HM blocks all UNICITY_* keys in user- + * supplied env to prevent boot-envelope spoofing). The trader exposes + * SET_STRATEGY directly via ACP — see `agentic_hosting/.../trader-main.ts:240` + * for the precedence rules (DEFAULT_STRATEGY → persisted → file → env → + * ACP). The ACP override sticks for the tenant's lifetime. + * + * Best-effort: a SET_STRATEGY failure is logged-and-swallowed because + * the tenant is otherwise healthy and the operator can re-issue the + * strategy update via `sphere trader set-strategy` manually. + */ +async function applyTrustedEscrows( + sphere: Sphere, + tenantAddress: string, + trustedEscrows: ReadonlyArray, +): Promise { + const transport = createAcpDmTransport(sphere.communications, { + tenantAddress, + timeoutMs: 30_000, + instanceId: 'sphere-cli', + instanceName: 'sphere-cli', + }); + try { + const res = await transport.sendCommand('SET_STRATEGY', { + trusted_escrows: Array.from(trustedEscrows), + }); + if (res.ok === false) { + process.stderr.write( + `sphere-cli: SET_STRATEGY (post-spawn trusted_escrows) returned ${res.error_code}: ${res.message}\n` + + ` Use \`sphere trader set-strategy --tenant ${tenantAddress} --trusted-escrows ${trustedEscrows.join(',')}\` to re-apply.\n`, + ); + } + } catch (e) { + process.stderr.write( + `sphere-cli: SET_STRATEGY (post-spawn trusted_escrows) threw ${String((e as Error).message ?? e)}\n` + + ` Use \`sphere trader set-strategy --tenant ${tenantAddress} --trusted-escrows ${trustedEscrows.join(',')}\` to re-apply.\n`, + ); + } finally { + await transport.dispose().catch(() => undefined); + } +} + async function safeDestroy(sphere: Sphere): Promise { try { await sphere.destroy(); } catch (e) { if (process.env['DEBUG']) { diff --git a/src/transport/dm-transport.ts b/src/transport/dm-transport.ts index 79bc99f..0d97711 100644 --- a/src/transport/dm-transport.ts +++ b/src/transport/dm-transport.ts @@ -13,7 +13,7 @@ * }); */ -import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { DirectMessage, SendMessageOptions } from '@unicitylabs/sphere-sdk'; import type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; import { parseHmcpResponse, byteLength, MAX_MESSAGE_SIZE } from './hmcp-types.js'; import { TimeoutError, TransportError } from './errors.js'; @@ -47,7 +47,11 @@ export interface DmTransportConfig { /** Narrow slice of Sphere.communications needed by DmTransport — injectable for testing. */ export interface SphereComms { - sendDM(recipient: string, content: string): Promise<{ recipientPubkey: string }>; + sendDM( + recipient: string, + content: string, + options?: SendMessageOptions, + ): Promise<{ recipientPubkey: string }>; onDirectMessage(handler: (message: DirectMessage) => void): () => void; } @@ -286,7 +290,12 @@ class DmTransportImpl implements DmTransport { `Request too large: ${byteLength(serialized)} bytes exceeds MAX_MESSAGE_SIZE (${MAX_MESSAGE_SIZE})`, ); } - const sent = await this.comms.sendDM(this.managerAddress, serialized); + // sphere-sdk#559 / PR #558 — HMCP RPC is short-lived (CLI exits + // shortly after the manager's reply). Suppressing the self-wrap + // halves the per-process relay-index pollution targeting the + // controller pubkey, which directly reduces the §3/§4 buffered- + // event noise the F1 soak measured at ~7 new events per spawn. + const sent = await this.comms.sendDM(this.managerAddress, serialized, { selfWrap: false }); // Cache the resolved pubkey on first send. Subsequent sends for the same // manager address produce the same pubkey, so concurrent writes are safe. if (!this.resolvedPubkey) {