diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts index 73f20a0..e6e55ea 100644 --- a/src/host/host-commands.ts +++ b/src/host/host-commands.ts @@ -29,6 +29,14 @@ import type { } from '../transport/hmcp-types.js'; import { hasDangerousKeys } from '../transport/hmcp-types.js'; import { initSphere, resolveManagerAddress } from './sphere-init.js'; +import { + ensureLocalHM, + localHmStatus, + stopLocalHM, + DEFAULT_HM_IMAGE, + type LocalHmMetadata, +} from './local-hm.js'; +import * as path from 'node:path'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -69,6 +77,22 @@ interface CmdOpts extends NameOrIdOpts { cmdTimeout?: string; } +interface LocalSpawnOpts { + hmImage?: string; + templatesFile?: string; + healthPort?: string; + baseDir?: string; +} + +interface LocalStopOpts { + keepContainer?: boolean; + baseDir?: string; +} + +interface LocalStatusOpts { + baseDir?: string; +} + // ============================================================================= // Helpers // ============================================================================= @@ -636,6 +660,174 @@ async function handleHelp(cmd: Command): Promise { }); } +// ============================================================================= +// Local HM helpers +// ============================================================================= +// +// `sphere host local-spawn / local-stop / local-status` manage a per-user +// agentic-hosting HM container scoped to the operator's wallet. These +// commands don't use the HMCP DM transport — they shell out to docker +// directly and only need the wallet's identity (to derive the controller +// pubkey, HOST_ID, container name, etc.). + +/** + * Default base directory under which sphere-cli stores per-controller + * local HM state. CWD-relative for consistency with the rest of + * sphere-cli (see sphere-init.ts:23-25). Override with `--base-dir`. + */ +const DEFAULT_LOCAL_HM_BASE_DIR = './.sphere-cli/local-hm'; + +function resolveLocalHmBaseDir(raw: string | undefined): string { + if (raw && raw.trim()) return raw.trim(); + return DEFAULT_LOCAL_HM_BASE_DIR; +} + +/** + * Initialise a Sphere just to read the wallet identity, then destroy + * it. None of the local HM lifecycle commands need DM transport, so we + * skip the longer-lived setup that `runWithTransport` does. Errors from + * `initSphere` bubble out — handled by the action wrappers. + */ +async function withWalletIdentity( + fn: (identity: { chainPubkey: string; directAddress: string | undefined; nametag: string | undefined }) => Promise, +): Promise { + const sphere = await initSphere(); + try { + const id = sphere.identity; + if (!id) throw new Error('Wallet has no identity. Run `sphere wallet init` first.'); + return await fn({ + chainPubkey: id.chainPubkey, + directAddress: id.directAddress, + nametag: id.nametag, + }); + } finally { + try { await sphere.destroy(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: sphere.destroy error: ${e}`); + } + } +} + +function parsePositivePort(raw: string | undefined, flag: string): number | undefined { + if (raw === undefined) return undefined; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0 || n > 65535) { + throw new Error(`Invalid ${flag}: ${raw} (must be 1..65535)`); + } + return n; +} + +async function handleLocalSpawn(cmd: Command, opts: LocalSpawnOpts): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + let healthPort: number | undefined; + try { + healthPort = parsePositivePort(opts.healthPort, '--health-port'); + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + try { + await withWalletIdentity(async (identity) => { + const baseDir = path.resolve(resolveLocalHmBaseDir(opts.baseDir)); + const meta = await ensureLocalHM({ + controllerPubkey: identity.chainPubkey, + baseDir, + templatesFile: opts.templatesFile, + image: opts.hmImage, + healthPort, + }); + if (json) { + printJson(meta); + } else { + process.stdout.write( + `Local HM ready: ${meta.containerName}\n` + + ` manager_pubkey: ${meta.managerPubkey}\n` + + ` manager_direct_address: ${meta.managerDirectAddress}\n` + + ` manager_nametag: ${meta.managerNametag}\n` + + ` host_id: ${meta.hostId}\n` + + ` image: ${meta.image}\n` + + ` health_port: 127.0.0.1:${meta.healthPort}\n`, + ); + } + }); + } catch (err) { + handleError(err, json); + } +} + +async function handleLocalStop(cmd: Command, opts: LocalStopOpts): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + try { + await withWalletIdentity(async (identity) => { + const result = await stopLocalHM({ + controllerPubkey: identity.chainPubkey, + keepContainer: opts.keepContainer === true, + }); + // baseDir isn't used by stopLocalHM but accept the flag so the + // command surface mirrors local-spawn / local-status. Suppress + // unused-var noise. + void opts.baseDir; + if (json) { + printJson(result); + } else if (result.stopped) { + process.stdout.write( + result.removed ? 'Local HM stopped and removed.\n' : 'Local HM stopped (container kept).\n', + ); + } else { + process.stdout.write('No local HM running for this wallet.\n'); + } + }); + } catch (err) { + handleError(err, json); + } +} + +async function handleLocalStatus(cmd: Command, opts: LocalStatusOpts): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + try { + await withWalletIdentity(async (identity) => { + const baseDir = path.resolve(resolveLocalHmBaseDir(opts.baseDir)); + const status = await localHmStatus({ + controllerPubkey: identity.chainPubkey, + baseDir, + }); + if (json) { + printJson(status); + } else { + process.stdout.write(`Container: ${status.containerName}\n`); + process.stdout.write(`Running: ${String(status.running)}\n`); + if (status.containerId) { + process.stdout.write(`ID: ${status.containerId}\n`); + } + if (status.metadata) { + printMetadataBlock(status.metadata); + } else { + process.stdout.write('Metadata: (no sidecar metadata — has the HM ever been spawned?)\n'); + } + } + }); + } catch (err) { + handleError(err, json); + } +} + +function printMetadataBlock(m: LocalHmMetadata): void { + process.stdout.write( + `Metadata:\n` + + ` manager_pubkey: ${m.managerPubkey}\n` + + ` manager_direct_address: ${m.managerDirectAddress}\n` + + ` manager_nametag: ${m.managerNametag}\n` + + ` host_id: ${m.hostId}\n` + + ` image: ${m.image}\n` + + ` health_port: 127.0.0.1:${m.healthPort}\n` + + ` created_at: ${m.createdAt}\n`, + ); +} + // ============================================================================= // Command tree // ============================================================================= @@ -747,6 +939,40 @@ export function createHostCommand(): Command { await handleHelp(this); }); + // ── Local HM lifecycle (per-user, no shared HM whitelist needed) ─── + // These don't use the DM transport. They wrap docker run/stop to bring + // up an agentic-hosting HM container scoped to the current wallet. + // Sister command: `sphere trader spawn` (chains local-spawn + a remote + // hm.spawn against the freshly-brought-up local HM). + + host + .command('local-spawn') + .description('Bring up a local host-manager container for the current wallet') + .option('--hm-image ', `Override HM container image (default: ${DEFAULT_HM_IMAGE})`) + .option('--templates-file ', 'Override templates.json (defaults to bundled trader-agent + escrow-service)') + .option('--health-port ', 'Override health-check port on 127.0.0.1 (default: derived from wallet)') + .option('--base-dir ', `Override per-controller data dir (default: ${DEFAULT_LOCAL_HM_BASE_DIR})`) + .action(async function (this: Command, opts: LocalSpawnOpts) { + await handleLocalSpawn(this, opts); + }); + + host + .command('local-stop') + .description('Stop the local host-manager container for the current wallet') + .option('--keep-container', 'Stop but do not remove the container (preserves docker logs)') + .option('--base-dir ', `Override per-controller data dir (default: ${DEFAULT_LOCAL_HM_BASE_DIR})`) + .action(async function (this: Command, opts: LocalStopOpts) { + await handleLocalStop(this, opts); + }); + + host + .command('local-status') + .description('Inspect the local host-manager container for the current wallet') + .option('--base-dir ', `Override per-controller data dir (default: ${DEFAULT_LOCAL_HM_BASE_DIR})`) + .action(async function (this: Command, opts: LocalStatusOpts) { + await handleLocalStatus(this, opts); + }); + // Attach the shared-options help text to every subcommand. Iterating after // construction keeps the subcommand definitions above small and ensures // any newly-added subcommand automatically inherits the help surface. diff --git a/src/host/local-hm.test.ts b/src/host/local-hm.test.ts new file mode 100644 index 0000000..946f3ad --- /dev/null +++ b/src/host/local-hm.test.ts @@ -0,0 +1,272 @@ +/** + * Pure-function tests for the local-HM helpers. The actual docker + * interaction is intentionally NOT covered here — it requires a docker + * daemon + several GB of agentic-hosting + trader images, and goes + * under the e2e smoke that ships with `npm run test:integration`. + * + * What's covered here: + * - walletPrefix, deriveHostId, deriveManagerNametag, deriveHealthPort + * (deterministic derivations from controller pubkey) + * - parseDriftError (parsing the HM's drift-guard error message) + * - buildHmEnv (HM env-bag synthesis — drives container startup) + * - ensureTemplatesFile (bundled-template write + custom-source copy) + * - readMetadata / writeMetadata roundtrip + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + walletPrefix, + deriveHostId, + deriveManagerNametag, + deriveHealthPort, + parseDriftError, + buildHmEnv, + ensureTemplatesFile, + readMetadata, + localHmDataDir, + localHmContainerName, + DEFAULT_TEMPLATES, +} from './local-hm.js'; + +describe('walletPrefix', () => { + it('returns the first 12 chars lowercased', () => { + expect(walletPrefix('0398e7df0a4580f59ceeb06bd13102d8c6e1e899058959e4dd4602ae4c0e08098a')) + .toBe('0398e7df0a45'); + }); + + it('handles uppercase input', () => { + expect(walletPrefix('0398E7DF0A4580F59CEEB06BD13102D8C6E1E899')) + .toBe('0398e7df0a45'); + }); + + it('throws when input is shorter than 12 chars', () => { + expect(() => walletPrefix('abc')).toThrow(/too short/); + }); +}); + +describe('deriveHostId', () => { + it('prefixes the wallet prefix with `u-`', () => { + expect(deriveHostId('0398e7df0a4580f59ceeb06bd13102d8c6e1e89905')) + .toBe('u-0398e7df0a45'); + }); + + it('produces a HOST_ID that matches agentic_hosting HOST_ID_RE', () => { + // From agentic_hosting/src/shared/config.ts:21 + const HOST_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/; + const id = deriveHostId('abcdef0123456789abcdef0123456789abcdef01'); + expect(HOST_ID_RE.test(id)).toBe(true); + }); +}); + +describe('deriveManagerNametag', () => { + it('mirrors agentic_hosting host-manager/main.ts:467 formula', () => { + // Strip non-alphanumeric, lowercase, max 12 chars, prefix with `m-`. + expect(deriveManagerNametag('u-0398e7df0a45')).toBe('m-u0398e7df0a4'); + expect(deriveManagerNametag('swap-soak')).toBe('m-swapsoak'); + // Truncates at 12 chars before adding the `m-` prefix. + expect(deriveManagerNametag('thisisaverylongthing')).toBe('m-thisisaveryl'); + }); +}); + +describe('deriveHealthPort', () => { + it('returns a port in [9401, 9401+1023]', () => { + const p = deriveHealthPort('abcdef01234567890'); + expect(p).toBeGreaterThanOrEqual(9401); + expect(p).toBeLessThanOrEqual(9401 + 1023); + }); + + it('is deterministic for the same pubkey', () => { + const a = deriveHealthPort('0398e7df0a4580f5'); + const b = deriveHealthPort('0398e7df0a4580f5'); + expect(a).toBe(b); + }); + + it('produces different ports for different wallets', () => { + // 5 hex chars give 2^20 buckets; collisions are possible but the + // odds for these two specific values are zero. If this fails after + // the formula changes, pick two different inputs. + const alice = deriveHealthPort('0398e7df0a45'); + const bob = deriveHealthPort('02ab558ab81a'); + expect(alice).not.toBe(bob); + }); +}); + +describe('localHmContainerName', () => { + it('uses the sphere-hm- prefix + wallet prefix', () => { + expect(localHmContainerName('0398e7df0a4580f59ceeb06bd13102d8c6e1e899')) + .toBe('sphere-hm-0398e7df0a45'); + }); +}); + +describe('localHmDataDir', () => { + it('joins base + wallet prefix', () => { + expect(localHmDataDir('/tmp/x', '0398e7df0a4580f59ceeb06bd13102d8c6e1e899')) + .toBe('/tmp/x/0398e7df0a45'); + }); +}); + +describe('parseDriftError', () => { + it('returns null when the marker is absent', () => { + 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. + expect(parseDriftError('Error: MANAGER_PUBKEY mismatch: env="abc"')).toBeNull(); + }); + + it('extracts the real pubkey from the drift-guard message', () => { + const msg = + 'Error: MANAGER_PUBKEY mismatch: env="placeholderxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", ' + + 'wallet="02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc". ' + + 'The wallet at ... does not match'; + const got = parseDriftError(msg); + expect(got).not.toBeNull(); + expect(got?.managerPubkey).toBe('02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc'); + expect(got?.managerDirectAddress).toBe( + 'DIRECT://02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc', + ); + }); + + it('lowercases the extracted pubkey', () => { + const msg = + 'MANAGER_PUBKEY mismatch: env="x", ' + + 'wallet="02AB558AB81A2343BEEED5FE95B159D3ED2A65ED51D7AAA7DCF92BC7ED35BCAAFC".'; + const got = parseDriftError(msg); + expect(got?.managerPubkey).toBe('02ab558ab81a2343beeed5fe95b159d3ed2a65ed51d7aaa7dcf92bc7ed35bcaafc'); + }); +}); + +describe('buildHmEnv', () => { + it('produces the required HM env keys', () => { + const env = buildHmEnv({ + controllerPubkey: '02ab', + hostId: 'u-test', + managerPubkey: '0279...', + managerDirectAddress: 'DIRECT://0279...', + healthPort: 9501, + }); + expect(env).toMatchObject({ + HOST_ID: 'u-test', + AUTHORIZED_CONTROLLERS: '02ab', + MANAGER_PUBKEY: '0279...', + MANAGER_DIRECT_ADDRESS: 'DIRECT://0279...', + UNICITY_HEALTH_PORT: '9501', + UNICITY_NETWORK: 'testnet', + LOG_LEVEL: 'info', + }); + }); + + it('honours --network override', () => { + const env = buildHmEnv({ + controllerPubkey: '02ab', + hostId: 'u-test', + managerPubkey: '0279...', + managerDirectAddress: 'DIRECT://0279...', + network: 'mainnet', + healthPort: 9401, + }); + expect(env['UNICITY_NETWORK']).toBe('mainnet'); + }); + + it('points TEMPLATES_PATH at the container-internal mount', () => { + const env = buildHmEnv({ + controllerPubkey: '02ab', + hostId: 'u-test', + managerPubkey: '0279...', + managerDirectAddress: 'DIRECT://0279...', + healthPort: 9401, + }); + // Pinned by Dockerfile.host-manager — not configurable on the HM side. + expect(env['TEMPLATES_PATH']).toBe('/app/config/templates.json'); + expect(env['SPHERE_MANAGER_DATA_DIR']).toBe('/app/sphere-manager'); + expect(env['PERSISTENCE_PATH']).toBe('/app/state/state.json'); + }); +}); + +describe('ensureTemplatesFile', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sphere-cli-test-')); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('writes the bundled templates.json with trader-agent + escrow-service', () => { + const target = ensureTemplatesFile(tmp); + expect(fs.existsSync(target)).toBe(true); + const parsed = JSON.parse(fs.readFileSync(target, 'utf8')) as { templates: Array<{ template_id: string }> }; + const ids = parsed.templates.map((t) => t.template_id); + expect(ids).toContain('trader-agent'); + expect(ids).toContain('escrow-service'); + }); + + it('copies a user-supplied templates file when provided', () => { + const customSource = path.join(tmp, 'custom.json'); + fs.writeFileSync(customSource, JSON.stringify({ templates: [{ template_id: 'custom' }] })); + const targetDir = fs.mkdtempSync(path.join(tmp, 'target-')); + const target = ensureTemplatesFile(targetDir, customSource); + const parsed = JSON.parse(fs.readFileSync(target, 'utf8')) as { templates: Array<{ template_id: string }> }; + expect(parsed.templates[0]?.template_id).toBe('custom'); + }); + + it('throws on missing templates-file', () => { + expect(() => ensureTemplatesFile(tmp, '/no/such/file.json')).toThrow(/not found/); + }); + + it('overwrites on each call (so a CLI upgrade re-publishes bundled defaults)', () => { + const target = ensureTemplatesFile(tmp); + fs.writeFileSync(target, '{}'); // mutate + ensureTemplatesFile(tmp); + const parsed = JSON.parse(fs.readFileSync(target, 'utf8')); + expect(parsed).toEqual(DEFAULT_TEMPLATES); + }); +}); + +describe('readMetadata roundtrip', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sphere-cli-meta-')); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns null when sidecar file is missing', () => { + expect(readMetadata(tmp)).toBeNull(); + }); + + it('returns null when sidecar file is malformed JSON', () => { + fs.writeFileSync(path.join(tmp, 'sphere-cli-meta.json'), '{not-json'); + expect(readMetadata(tmp)).toBeNull(); + }); + + it('returns null when shape is wrong', () => { + fs.writeFileSync(path.join(tmp, 'sphere-cli-meta.json'), JSON.stringify({ foo: 'bar' })); + expect(readMetadata(tmp)).toBeNull(); + }); + + it('returns the metadata when shape is valid', () => { + const m = { + controllerPubkey: '02ab', + managerPubkey: '0279', + managerDirectAddress: 'DIRECT://0279', + managerNametag: 'm-test', + hostId: 'u-test', + containerName: 'sphere-hm-02ab', + image: 'ghcr.io/x:y', + healthPort: 9401, + createdAt: '2026-06-10T00:00:00Z', + }; + fs.writeFileSync(path.join(tmp, 'sphere-cli-meta.json'), JSON.stringify(m)); + expect(readMetadata(tmp)).toEqual(m); + }); +}); diff --git a/src/host/local-hm.ts b/src/host/local-hm.ts new file mode 100644 index 0000000..f26d427 --- /dev/null +++ b/src/host/local-hm.ts @@ -0,0 +1,908 @@ +/** + * Per-user local host-manager (HM) container lifecycle. + * + * Each developer who wants to run trader / escrow tenants locally needs + * their own HM scoped to their controller wallet (see + * `docs/local-hm.md`). The public HM at `m-swap-soak` reserves its + * `AUTHORIZED_CONTROLLERS` slot for shared services like the public + * escrow — it cannot whitelist alice + bob + charlie per soak run. + * + * This module brings that local HM up as a Docker container without + * any docker-compose boilerplate. Shells out to the `docker` CLI rather + * than depending on `dockerode`: keeps the sphere-cli surface light and + * makes failures (image missing, socket permission) trivial to + * reproduce by hand. + * + * Two-shot bootstrap. The agentic-hosting HM's drift guard fails the + * very first boot when `MANAGER_PUBKEY`/`MANAGER_DIRECT_ADDRESS` env + * vars don't match the wallet it just generated (intentional — protects + * against pointing the manager at the wrong data dir post-misdeploy). + * The error message embeds the real pubkey/direct address; we scrape + * those, stop the failed container, and restart with corrected env. + * The wallet persists across the restart in the bind-mounted volume so + * the second boot succeeds cleanly. After that, subsequent invocations + * reuse the same wallet → no re-bootstrap. + * + * Idempotency. `ensureLocalHM()` checks for an existing running HM + * container by name first and returns its sidecar metadata if present; + * `stopLocalHM()` is a no-op if no container exists. + * + * @see vrogojin/agentic_hosting Dockerfile.host-manager (image build) + * @see vrogojin/agentic_hosting/src/host-manager/main.ts:484-514 + * (manager_wallet_created_RECORD_MNEMONIC + drift guard) + * @see GitHub sphere-cli#48 (issue spec) + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Default agentic-hosting HM image. The `unicitynetwork` org's + * `publish-images.sh` script tags every published HM as + * `/agentic-hosting/host-manager:` (see the script's + * IMAGES table — registry defaults to `ghcr.io/unicitynetwork/agentic-hosting`, + * but the per-image build paths flatten that to `agentic-hosting/host-manager`). + * + * Override with `--hm-image`. Local development builds with the + * docker-compose file in vrogojin/agentic_hosting/docker yield + * `docker-host-manager:latest`; pass that via `--hm-image` or + * `SPHERE_LOCAL_HM_IMAGE` until the public image lands. + */ +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. + */ +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'; + +/** + * docker socket path (`/var/run/docker.sock`). Mounted read-write into + * the HM so it can spawn tenant containers via dockerode. The local user + * must be in the `docker` group for this mount to be writable. + */ +const DOCKER_SOCKET = '/var/run/docker.sock'; + +/** + * Two-shot bootstrap parameters. The HM's first boot fails fast with a + * ConfigError after Sphere.init returns — the wallet write itself takes + * <2 s. We poll the container logs at 500ms cadence until the error + * line appears, then restart. + */ +const BOOTSTRAP_LOG_POLL_MS = 500; +const BOOTSTRAP_LOG_TIMEOUT_MS = 60_000; +const READY_LOG_POLL_MS = 500; +const DEFAULT_READY_TIMEOUT_MS = 90_000; + +/** + * The HM log line we wait for on healthy boot. Emitted by the + * structured logger in host-manager/main.ts:515 after the drift guard + * passes. + */ +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. + */ +const DRIFT_GUARD_MARKER = 'MANAGER_PUBKEY mismatch'; + +/** + * The placeholder values we ship to the HM on first boot. They MUST be + * valid secp256k1 hex (parseManagerConfig validates the format before + * the drift guard runs). The exact value doesn't matter — they'll be + * overwritten on the second boot — but using all-zeros risks the + * scep256k1 validator rejecting an invalid curve point. We use a known + * valid generator-derived pubkey. + * + * `01` as the private key gives `0279be667ef9dcbbac55a06295ce870b07...` + * (compressed secp256k1 generator G). This is a well-known valid pubkey + * and is obviously not a real manager key, so any operator who sees it + * in logs knows it's a bootstrap placeholder. + */ +const PLACEHOLDER_PUBKEY = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; +const PLACEHOLDER_DIRECT_ADDRESS = + `DIRECT://${PLACEHOLDER_PUBKEY}`; + +// ============================================================================= +// Types +// ============================================================================= + +export interface LocalHmConfig { + /** + * The controller wallet's chain pubkey (33-byte secp256k1 compressed, + * 66 hex chars). The HM is brought up with this as its only + * `AUTHORIZED_CONTROLLERS` entry. + */ + readonly controllerPubkey: string; + /** + * Base directory under which the local HM keeps its persistent state + * (manager wallet, state.json, tenant records). One subdirectory per + * controller pubkey prefix; see `localHmDataDir()`. + */ + readonly baseDir: string; + /** + * Path to a templates.json the HM should consult. When omitted, the + * bundled `DEFAULT_TEMPLATES` (trader-agent + escrow-service) are + * written into the per-controller data dir. Pass an explicit path to + * point at a local agentic_hosting checkout instead. + */ + readonly templatesFile?: string; + /** + * HM container image reference. Defaults to {@link DEFAULT_HM_IMAGE}. + * Override with `--hm-image` or `SPHERE_LOCAL_HM_IMAGE`. + */ + readonly image?: string; + /** + * Health-port to expose on `127.0.0.1`. The HM listens on 9401 + * internally; we map each per-developer HM to a different host port + * derived from the wallet prefix to avoid collisions. Pass an explicit + * value to override. + */ + readonly healthPort?: number; +} + +/** + * Sidecar metadata written to `${localHmDataDir}/sphere-cli-meta.json` + * after a successful boot. Read on `local-status` and re-used by + * subsequent `local-spawn` calls so we don't re-run the two-shot + * bootstrap unnecessarily. + */ +export interface LocalHmMetadata { + readonly controllerPubkey: string; + readonly managerPubkey: string; + readonly managerDirectAddress: string; + readonly managerNametag: string; + readonly hostId: string; + readonly containerName: string; + readonly image: string; + readonly healthPort: number; + readonly createdAt: string; +} + +export interface LocalHmStatus { + readonly running: boolean; + readonly containerName: string; + readonly metadata: LocalHmMetadata | null; + /** + * Only set when `running === true`. The actual docker container id + * (long form). Useful for `docker logs` / `docker exec` follow-ups. + */ + readonly containerId?: string; +} + +// ============================================================================= +// Path conventions +// ============================================================================= + +/** + * The wallet-pubkey-prefix used to name container + scope data dirs. + * First 12 chars of the chain pubkey hex — long enough to avoid + * realistic collisions on a single developer's machine, short enough + * to fit in container names + paths cleanly. + */ +export function walletPrefix(controllerPubkey: string): string { + if (controllerPubkey.length < 12) { + throw new Error( + `controllerPubkey too short (${controllerPubkey.length} hex chars; need >= 12)`, + ); + } + return controllerPubkey.slice(0, 12).toLowerCase(); +} + +/** + * Per-controller data directory. All paths below it are owned by + * sphere-cli; the operator should never need to touch them directly. + */ +export function localHmDataDir(baseDir: string, controllerPubkey: string): string { + return path.join(baseDir, walletPrefix(controllerPubkey)); +} + +/** + * Stable container name. `sphere-hm-` prefix keeps every local HM + * grouped under `docker ps --filter name=sphere-hm-`. + */ +export function localHmContainerName(controllerPubkey: string): string { + return `sphere-hm-${walletPrefix(controllerPubkey)}`; +} + +/** + * Derive a deterministic health port from the wallet prefix so two + * developers on the same host don't collide on `127.0.0.1:9401`. + * + * The agentic-hosting HM listens on `UNICITY_HEALTH_PORT` for a + * diagnostics HTTP endpoint. Different per-developer HMs need + * different host-side ports; the in-container port stays 9401. + * + * Range: `[9401, 9401 + 1023]` — 1024 distinct slots, derived as the + * first 5 hex digits of the prefix mod 1024. Deterministic so re-runs + * pick the same port and operators can curl it without a registry. + */ +export function deriveHealthPort(controllerPubkey: string): number { + const slot = parseInt(controllerPubkey.slice(0, 5), 16) & 0x3ff; // [0, 1023] + return 9401 + slot; +} + +/** + * Manager nametag — same formula as agentic_hosting/host-manager/main.ts:467 + * (`m-${host_id}` slugified to a-z0-9, max 12 chars, lowercased). MUST + * stay aligned with the HM so the nametag the HM publishes matches what + * we record in sidecar metadata. + */ +export function deriveManagerNametag(hostId: string): string { + return `m-${hostId.replace(/[^a-z0-9]/gi, '').slice(0, 12).toLowerCase()}`; +} + +/** + * `HOST_ID` for the agentic-hosting HM. Must match + * `^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$` (see `agentic_hosting/src/shared/config.ts` + * line 21 `HOST_ID_RE`). Using `u-` keeps it visually + * distinct from the manager nametag pattern (which adds an `m-`). + */ +export function deriveHostId(controllerPubkey: string): string { + return `u-${walletPrefix(controllerPubkey)}`; +} + +// ============================================================================= +// Sidecar metadata I/O +// ============================================================================= + +function metadataPath(dataDir: string): string { + return path.join(dataDir, 'sphere-cli-meta.json'); +} + +export function readMetadata(dataDir: string): LocalHmMetadata | null { + const p = metadataPath(dataDir); + if (!fs.existsSync(p)) return null; + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as unknown; + if (!isLocalHmMetadata(raw)) return null; + return raw; + } catch { + return null; + } +} + +function isLocalHmMetadata(v: unknown): v is LocalHmMetadata { + if (typeof v !== 'object' || v === null) return false; + const o = v as Record; + return ( + typeof o['controllerPubkey'] === 'string' && + typeof o['managerPubkey'] === 'string' && + typeof o['managerDirectAddress'] === 'string' && + typeof o['managerNametag'] === 'string' && + typeof o['hostId'] === 'string' && + typeof o['containerName'] === 'string' && + typeof o['image'] === 'string' && + typeof o['healthPort'] === 'number' && + typeof o['createdAt'] === 'string' + ); +} + +function writeMetadata(dataDir: string, m: LocalHmMetadata): void { + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(metadataPath(dataDir), JSON.stringify(m, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); +} + +// ============================================================================= +// docker CLI wrappers +// ============================================================================= +// +// Shells out via `child_process.execFile` (no shell metachar expansion — +// passes argv directly to the kernel). Keeps the surface narrow and +// audit-friendly compared with `child_process.exec` or a `dockerode` +// dependency. The CLI's `docker` binary is widely available on dev hosts +// already. + +interface ContainerInspectResult { + readonly id: string; + readonly running: boolean; + readonly image: string; +} + +async function dockerExists(): Promise { + try { + await execFileAsync('docker', ['version', '--format', '{{.Client.Version}}']); + return true; + } catch { + return false; + } +} + +async function inspectContainer(name: string): Promise { + try { + const { stdout } = await execFileAsync( + 'docker', + ['inspect', '--format', '{{.Id}}|{{.State.Running}}|{{.Config.Image}}', name], + ); + const parts = stdout.trim().split('|'); + if (parts.length !== 3) return null; + return { + id: parts[0] ?? '', + running: (parts[1] ?? '').toLowerCase() === 'true', + image: parts[2] ?? '', + }; + } catch { + return null; + } +} + +async function readContainerLogs(name: string, lines: number = 200): Promise { + try { + const { stdout, stderr } = await execFileAsync('docker', [ + 'logs', '--tail', String(lines), name, + ], { maxBuffer: 16 * 1024 * 1024 }); + // Container processes are free to write to either stream; merge so + // callers don't have to chase log lines across two buffers. + return `${stdout}${stderr}`; + } catch { + return ''; + } +} + +async function dockerRm(name: string): Promise { + // `--force` so we don't need a separate stop + sleep when scrapping + // a container that's still running (e.g. mid-bootstrap retry). + // Ignore failure — caller's intent is "make sure it's gone." + try { + await execFileAsync('docker', ['rm', '--force', name]); + } catch { + // no-op + } +} + +async function dockerStop(name: string): Promise { + try { + await execFileAsync('docker', ['stop', name]); + } catch { + // no-op + } +} + +interface DockerRunArgs { + readonly name: string; + readonly image: string; + readonly env: Record; + readonly volumes: ReadonlyArray<{ host: string; container: string; readonly?: boolean }>; + readonly ports: ReadonlyArray<{ host: number; container: number }>; + readonly groupAddDockerGid?: number; +} + +async function dockerRunDetached(args: DockerRunArgs): Promise { + const argv: string[] = ['run', '--detach', '--name', args.name, '--restart', 'unless-stopped']; + + // group_add for the docker socket — without this, mounting the socket + // works but the container can't write to it (EACCES). + if (args.groupAddDockerGid !== undefined) { + argv.push('--group-add', String(args.groupAddDockerGid)); + } + + for (const [k, v] of Object.entries(args.env)) { + argv.push('--env', `${k}=${v}`); + } + for (const vol of args.volumes) { + const ro = vol.readonly === true ? ':ro' : ''; + argv.push('--volume', `${vol.host}:${vol.container}${ro}`); + } + for (const p of args.ports) { + // Bind to 127.0.0.1 only — the health endpoint is diagnostics-only, + // we never want it exposed on 0.0.0.0 by default. + argv.push('--publish', `127.0.0.1:${p.host}:${p.container}`); + } + argv.push(args.image); + + const { stdout } = await execFileAsync('docker', argv); + return stdout.trim(); +} + +/** + * Read the host-side gid of the `docker` group. The HM container's + * user (`node`) needs supplementary group membership matching this + * gid to write to the bind-mounted docker socket. Falls back to the + * `999` convention used in the agentic_hosting docker-compose example + * if `/etc/group` doesn't have a `docker` entry. + */ +export function detectDockerGid(): number { + try { + const groupFile = fs.readFileSync('/etc/group', 'utf8'); + const match = groupFile.match(/^docker:[^:]*:(\d+):/m); + if (match && match[1]) return Number.parseInt(match[1], 10); + } catch { + // /etc/group not readable — fall back to default. + } + return 999; +} + +// ============================================================================= +// Two-shot bootstrap helpers +// ============================================================================= + +/** + * 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. + * + * Returns null when the marker isn't present yet (poll again). + */ +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(); + return { + managerPubkey, + managerDirectAddress: `DIRECT://${managerPubkey}`, + }; +} + +/** + * Poll a container's logs until either the success marker or the + * drift-guard marker appears. Returns whichever fired first, or null + * on timeout. + */ +type WaitForLogResult = + | { kind: 'ready' } + | { kind: 'drift'; managerPubkey: string; managerDirectAddress: string } + | { kind: 'timeout'; lastLogs: string }; + +async function waitForBootSignal(containerName: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastLogs = ''; + while (Date.now() < deadline) { + lastLogs = await readContainerLogs(containerName, 500); + const drift = parseDriftError(lastLogs); + if (drift) { + return { kind: 'drift', ...drift }; + } + if (lastLogs.includes(READY_LOG_MARKER)) { + return { kind: 'ready' }; + } + await new Promise((r) => setTimeout(r, BOOTSTRAP_LOG_POLL_MS)); + } + return { kind: 'timeout', lastLogs }; +} + +// ============================================================================= +// Build env / docker args for the HM +// ============================================================================= + +/** + * Compute the HOST_ID + MANAGER_PUBKEY env bag for the HM. Pure + * function — unit-tested in `local-hm.test.ts` without touching docker + * or the filesystem. + * + * `managerPubkey`/`managerDirectAddress` accept the placeholder values + * for the bootstrap boot, then the real wallet values for the second + * boot. Callers should pass the appropriate value for the phase. + */ +export function buildHmEnv(opts: { + readonly controllerPubkey: string; + readonly hostId: string; + readonly managerPubkey: string; + readonly managerDirectAddress: string; + readonly network?: string; + readonly healthPort: number; +}): Record { + return { + HOST_ID: opts.hostId, + AUTHORIZED_CONTROLLERS: opts.controllerPubkey, + MANAGER_PUBKEY: opts.managerPubkey, + 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, + PERSISTENCE_PATH: path.posix.join(HM_STATE_PATH_IN_CONTAINER, 'state.json'), + DOCKER_SOCKET: DOCKER_SOCKET, + UNICITY_NETWORK: opts.network ?? 'testnet', + UNICITY_HEALTH_PORT: String(opts.healthPort), + LOG_LEVEL: 'info', + }; +} + +// ============================================================================= +// Templates.json bundling +// ============================================================================= + +/** + * Default templates.json bundled with sphere-cli. Mirrors the + * agentic_hosting/config/templates.json entries for trader-agent + + * 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. + */ +export const DEFAULT_TEMPLATES: { templates: ReadonlyArray } = { + templates: [ + { + template_id: 'trader-agent', + image: 'ghcr.io/vrogojin/agentic-hosting/trader:v0.1', + entrypoint: ['node', '/app/dist/acp-adapter/main.js'], + env_defaults: { + LOG_LEVEL: 'info', + SPHERE_NETWORK: 'testnet', + TRADER_SCAN_INTERVAL_MS: '30000', + TRADER_MAX_ACTIVE_INTENTS: '10', + }, + resources: { memory_mb: 512, pids_limit: 256 }, + }, + { + template_id: 'escrow-service', + image: 'ghcr.io/vrogojin/agentic-hosting/escrow:v0.3', + entrypoint: ['node', '/app/dist/acp-adapter/main.js'], + env_defaults: { + LOG_LEVEL: 'info', + SPHERE_NETWORK: 'testnet', + SWAP_TIMEOUT_DEFAULT: '300', + MAX_PENDING_SWAPS: '100', + }, + resources: { memory_mb: 1024, pids_limit: 512 }, + }, + ], +}; + +/** + * Write the bundled templates.json into the per-controller data dir. + * Idempotent — overwrites on every call so a CLI upgrade with new + * template defaults takes effect without a rebuild. If the operator + * supplied their own templates file (via `--templates-file`), we copy + * theirs in instead. + */ +export function ensureTemplatesFile(dataDir: string, sourceFile?: string): string { + fs.mkdirSync(dataDir, { recursive: true }); + const target = path.join(dataDir, 'templates.json'); + if (sourceFile) { + if (!fs.existsSync(sourceFile)) { + throw new Error(`Templates file not found: ${sourceFile}`); + } + fs.copyFileSync(sourceFile, target); + } else { + fs.writeFileSync(target, JSON.stringify(DEFAULT_TEMPLATES, null, 2) + '\n', 'utf8'); + } + return target; +} + +// ============================================================================= +// Public lifecycle API +// ============================================================================= + +/** + * Get the current status of the local HM container for a given + * controller wallet. Combines a docker inspect lookup with the + * persisted sidecar metadata. + * + * Does NOT modify any state. Safe to call repeatedly. + */ +export async function localHmStatus(opts: { + controllerPubkey: string; + baseDir: string; +}): Promise { + const containerName = localHmContainerName(opts.controllerPubkey); + const dataDir = localHmDataDir(opts.baseDir, opts.controllerPubkey); + const metadata = readMetadata(dataDir); + const inspect = await inspectContainer(containerName); + if (!inspect) { + return { running: false, containerName, metadata }; + } + return { + running: inspect.running, + containerName, + containerId: inspect.id, + metadata, + }; +} + +/** + * Ensure a local HM container is running for the controller wallet. + * Idempotent — returns the existing metadata if the container is + * already up. Otherwise runs the full two-shot bootstrap. + * + * The returned metadata's `managerDirectAddress` is the address the + * caller should pass as `--manager` for subsequent `sphere host …` + * subcommands against this local HM. + */ +export async function ensureLocalHM(config: LocalHmConfig): Promise { + if (!(await dockerExists())) { + throw new Error( + 'docker CLI not found. Install Docker Engine and ensure your user is in the docker group.', + ); + } + + const image = config.image ?? process.env['SPHERE_LOCAL_HM_IMAGE'] ?? DEFAULT_HM_IMAGE; + const healthPort = config.healthPort ?? deriveHealthPort(config.controllerPubkey); + const containerName = localHmContainerName(config.controllerPubkey); + const dataDir = localHmDataDir(config.baseDir, config.controllerPubkey); + const hostId = deriveHostId(config.controllerPubkey); + const managerNametag = deriveManagerNametag(hostId); + + const existing = await inspectContainer(containerName); + if (existing && existing.running) { + const meta = readMetadata(dataDir); + if (meta) return meta; + // Container is running but we have no sidecar metadata — most + // likely a stale run from before sphere-cli wrote metadata. + // Reset and restart so we have a known-good record. + await dockerRm(containerName); + } else if (existing) { + // Container exists but is stopped — clear it before we start a + // fresh boot. We do this regardless of the metadata because a + // stopped container holds its name lock; docker run would fail + // with "Conflict. The container name is already in use". + await dockerRm(containerName); + } + + // Ensure data + bind directories exist with the right ownership for + // the container's `node` user (uid 1000 in node:22-alpine). Without + // chown, the first write inside the container hits EACCES. + const walletDir = path.join(dataDir, 'manager-wallet'); + const stateDir = path.join(dataDir, 'state'); + const tenantsDir = path.join(dataDir, 'tenants'); + for (const dir of [walletDir, stateDir, tenantsDir]) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Chown is a separate step because mkdirSync inherits the calling + // user's uid. We pass through `chown -R 1000:1000` via the docker + // CLI so we don't need sudo locally. Suppress errors — on rootless + // docker the mount uses uid mapping and chown isn't needed. + await chownForContainer(walletDir, stateDir, tenantsDir); + + const templatesFile = ensureTemplatesFile(dataDir, config.templatesFile); + + // ── Phase 1: bootstrap boot ──────────────────────────────────────── + // Start with placeholder MANAGER_PUBKEY / MANAGER_DIRECT_ADDRESS. The + // HM's drift guard will fail, but in the process it logs the wallet's + // real pubkey, which we scrape on the next step. + const bootstrapEnv = buildHmEnv({ + controllerPubkey: config.controllerPubkey, + hostId, + managerPubkey: PLACEHOLDER_PUBKEY, + managerDirectAddress: PLACEHOLDER_DIRECT_ADDRESS, + healthPort, + }); + + await dockerRunDetached({ + name: containerName, + image, + env: bootstrapEnv, + volumes: [ + { host: DOCKER_SOCKET, container: DOCKER_SOCKET }, + { host: templatesFile, container: HM_TEMPLATES_PATH_IN_CONTAINER, readonly: true }, + { host: walletDir, container: HM_WALLET_PATH_IN_CONTAINER }, + { host: stateDir, container: HM_STATE_PATH_IN_CONTAINER }, + { host: tenantsDir, container: HM_TENANTS_PATH_IN_CONTAINER }, + ], + ports: [{ host: healthPort, container: 9401 }], + groupAddDockerGid: detectDockerGid(), + }); + + const bootstrapResult = await waitForBootSignal(containerName, BOOTSTRAP_LOG_TIMEOUT_MS); + if (bootstrapResult.kind === 'timeout') { + await dockerRm(containerName); + throw new Error( + `Local HM bootstrap timed out after ${BOOTSTRAP_LOG_TIMEOUT_MS}ms. Tail of logs:\n${bootstrapResult.lastLogs.slice(-2000)}`, + ); + } + + let realManagerPubkey: string; + let realManagerDirectAddress: string; + + if (bootstrapResult.kind === 'ready') { + // Unexpected but acceptable: a re-used wallet directory might have + // already-matching env vars (e.g., metadata was stale but the + // placeholder happens to match a previously-recorded wallet). Just + // capture the live wallet info from logs. + 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 = liveInfo.managerPubkey; + realManagerDirectAddress = liveInfo.managerDirectAddress; + } else { + realManagerPubkey = bootstrapResult.managerPubkey; + realManagerDirectAddress = bootstrapResult.managerDirectAddress; + + // ── Phase 2: restart with corrected env ────────────────────────── + await dockerRm(containerName); + + const realEnv = buildHmEnv({ + controllerPubkey: config.controllerPubkey, + hostId, + managerPubkey: realManagerPubkey, + managerDirectAddress: realManagerDirectAddress, + healthPort, + }); + + await dockerRunDetached({ + name: containerName, + image, + env: realEnv, + volumes: [ + { host: DOCKER_SOCKET, container: DOCKER_SOCKET }, + { host: templatesFile, container: HM_TEMPLATES_PATH_IN_CONTAINER, readonly: true }, + { host: walletDir, container: HM_WALLET_PATH_IN_CONTAINER }, + { host: stateDir, container: HM_STATE_PATH_IN_CONTAINER }, + { host: tenantsDir, container: HM_TENANTS_PATH_IN_CONTAINER }, + ], + ports: [{ host: healthPort, container: 9401 }], + groupAddDockerGid: detectDockerGid(), + }); + + const ready = await waitForReady(containerName, DEFAULT_READY_TIMEOUT_MS); + if (!ready.ok) { + 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)}`, + ); + } + } + + const meta: LocalHmMetadata = { + controllerPubkey: config.controllerPubkey, + managerPubkey: realManagerPubkey, + managerDirectAddress: realManagerDirectAddress, + managerNametag, + hostId, + containerName, + image, + healthPort, + createdAt: new Date().toISOString(), + }; + writeMetadata(dataDir, meta); + return meta; +} + +/** + * Stop + remove the local HM container for a controller wallet. + * + * - `keepData: false` (default): leave the bind-mounted data dir + * intact. The wallet, state.json, tenant records all persist for + * next time. Setting `keepData: true` is currently a no-op (the + * data is preserved either way) and is reserved for a future + * `--purge-data` option. + * - `keepContainer: true`: skip `docker rm` (only `docker stop`). + * Useful for `--keep-hm` semantics in `sphere trader stop`. + */ +export async function stopLocalHM(opts: { + controllerPubkey: string; + keepContainer?: boolean; +}): Promise<{ stopped: boolean; removed: boolean }> { + const name = localHmContainerName(opts.controllerPubkey); + const inspect = await inspectContainer(name); + if (!inspect) { + return { stopped: false, removed: false }; + } + await dockerStop(name); + if (opts.keepContainer === true) { + return { stopped: true, removed: false }; + } + await dockerRm(name); + return { stopped: true, removed: true }; +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * Wait for the `sphere_initialized` log line. Used by the post-bootstrap + * boot to confirm the HM is actually serving HMCP traffic before the + * caller (`sphere trader spawn`) issues spawn requests against it. + */ +async function waitForReady( + containerName: string, + timeoutMs: number, +): Promise<{ ok: boolean; lastLogs: string }> { + const deadline = Date.now() + timeoutMs; + let lastLogs = ''; + while (Date.now() < deadline) { + lastLogs = await readContainerLogs(containerName, 500); + if (lastLogs.includes(READY_LOG_MARKER)) { + return { ok: true, lastLogs }; + } + await new Promise((r) => setTimeout(r, READY_LOG_POLL_MS)); + } + return { ok: false, lastLogs }; +} + +/** + * After a successful boot, the HM logs a `sphere_initialized` line + * carrying `direct_address` and `pubkey` (truncated). We use this to + * recover the manager identity when the second-boot path isn't needed + * (e.g., the bootstrap actually succeeded because the wallet already + * matched our placeholder somehow — rare but possible if the operator + * is recovering from a partial prior run). + * + * Returns null when the line isn't parseable (caller falls back to a + * loud error message). + */ +function parseManagerIdentityFromLogs( + logs: string, +): { managerPubkey: string; managerDirectAddress: string } | null { + // Logger emits `sphere_initialized` with structured fields; the + // formatting varies (JSON vs human) by LOG_FORMAT. Match the + // `direct_address` field defensively against either. + const directMatch = logs.match(/direct_address["':=\s]+(DIRECT:\/\/[0-9a-fA-F]+)/); + if (!directMatch || !directMatch[1]) return null; + const directAddress = directMatch[1]; + // Pubkey embedded in the DIRECT://. The HM's direct address + // format prefixes a 12-char address-type tag before the pubkey, but + // the trailing 66 hex chars are always the chain pubkey. We pull + // those out for the metadata record so it's consistent with the + // bootstrap path (which already has just the pubkey). + const tail = directAddress.replace(/^DIRECT:\/\//, ''); + const last66 = tail.slice(-66); + if (!/^[0-9a-fA-F]{66}$/.test(last66)) return null; + return { + managerPubkey: last66.toLowerCase(), + managerDirectAddress: directAddress, + }; +} + +/** + * Ensure bind-mounted dirs are writable by the container's `node` + * user (uid 1000 in node:22-alpine). Uses a throwaway alpine container + * so we don't need `sudo chown` locally and the operation works the + * same way whether the operator is rootful, rootless, or under + * podman-compat. + */ +async function chownForContainer(...dirs: string[]): Promise { + if (dirs.length === 0) return; + try { + // Mount /target as a writable workspace, chown its top-level + // children to 1000:1000. Quiet single shot — no need for + // `--rm` since we use `--rm` directly. + // We mount each dir individually because Docker doesn't accept + // multiple --volume flags pointing at the same path. + for (const dir of dirs) { + await execFileAsync('docker', [ + 'run', '--rm', + '--volume', `${dir}:/target`, + 'alpine:3.20', + 'chown', '-R', '1000:1000', '/target', + ]); + } + } catch { + // Best-effort. If the user has correctly configured rootless + // docker (or runs as uid 1000), this isn't needed. The HM will + // surface a clear EACCES if it can't write. + } +} diff --git a/src/trader/spawn.test.ts b/src/trader/spawn.test.ts new file mode 100644 index 0000000..9425c51 --- /dev/null +++ b/src/trader/spawn.test.ts @@ -0,0 +1,130 @@ +/** + * Pure-function tests for the `sphere trader spawn / stop` wrapper. The + * orchestration that touches docker, the Sphere DM transport, and the + * agentic-hosting HM is covered by the e2e smoke (out of scope for unit + * tests). What's covered here: + * - deriveTenantName (wallet-nametag → instance-name fallback) + * - buildTraderEnv (mirrors trader-service/test/e2e-live/helpers/tenant-fixture.ts:310-365) + * - isLiveState (drives the keep-hm-alive ref-count decision) + */ + +import { describe, it, expect } from 'vitest'; +import { + deriveTenantName, + buildTraderEnv, + isLiveState, +} from './spawn.js'; + +describe('deriveTenantName', () => { + it('uses the explicit name when provided', () => { + expect(deriveTenantName('alice', '0398e7df0a45', 'my-trader')).toBe('my-trader'); + }); + + it('falls back to -trader when no explicit name', () => { + expect(deriveTenantName('alice', '0398e7df0a45', undefined)).toBe('alice-trader'); + }); + + it('lowercases the nametag', () => { + expect(deriveTenantName('Alice', '0398e7df0a45', undefined)).toBe('alice-trader'); + }); + + it('falls back to -trader when no nametag', () => { + expect(deriveTenantName(undefined, '0398e7df0a4580f59ceeb06bd13102d8c6e1e89905', undefined)) + .toBe('0398e7df0a45-trader'); + }); + + it('treats empty nametag as missing', () => { + expect(deriveTenantName('', '0398e7df0a4580f59ceeb06bd13102d8c6e1e89905', undefined)) + .toBe('0398e7df0a45-trader'); + }); + + it('trims explicit names', () => { + expect(deriveTenantName(undefined, '0398e7df0a45', ' my-trader ')).toBe('my-trader'); + }); +}); + +describe('buildTraderEnv', () => { + it('always sets UNICITY_CONTROLLER_PUBKEY', () => { + const env = buildTraderEnv({ controllerPubkey: '0398' }); + expect(env['UNICITY_CONTROLLER_PUBKEY']).toBe('0398'); + }); + + 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', () => { + 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', + testFund: 'deadbeef:1000,cafebabe:500', + }); + expect(env['TRADER_TEST_FUND']).toBe('deadbeef:1000,cafebabe:500'); + expect(env['TRADER_FAULT_INJECTION_ALLOWED']).toBe('1'); + }); + + it('omits both TRADER_TEST_FUND keys when not set', () => { + const env = buildTraderEnv({ controllerPubkey: '0398' }); + expect(env).not.toHaveProperty('TRADER_TEST_FUND'); + 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'); + }); +}); + +describe('isLiveState', () => { + it('treats CREATED, BOOTING, RUNNING as live', () => { + expect(isLiveState('CREATED')).toBe(true); + expect(isLiveState('BOOTING')).toBe(true); + expect(isLiveState('RUNNING')).toBe(true); + }); + + it('treats STOPPED, FAILED as not live', () => { + expect(isLiveState('STOPPED')).toBe(false); + expect(isLiveState('FAILED')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isLiveState('running')).toBe(true); + expect(isLiveState('Stopped')).toBe(false); + }); + + it('treats unknown states as not live', () => { + expect(isLiveState('PAUSED')).toBe(false); + expect(isLiveState('')).toBe(false); + }); +}); diff --git a/src/trader/spawn.ts b/src/trader/spawn.ts new file mode 100644 index 0000000..a7a09d5 --- /dev/null +++ b/src/trader/spawn.ts @@ -0,0 +1,548 @@ +/** + * `sphere trader spawn` / `sphere trader stop` orchestration. + * + * The wrapper chains two existing primitives: + * 1. `ensureLocalHM` (host/local-hm.ts) — brings up an agentic-hosting + * HM container scoped to the operator's wallet + * 2. HMCP `hm.spawn` / `hm.stop` over Sphere DMs against that local HM + * + * The point is to make `sphere wallet use alice && sphere trader spawn` + * a single-command bring-up. Without this wrapper, the operator has to + * hand-orchestrate the HM container, scrape its drift-guard error, + * restart it with corrected env, then drive `sphere host spawn` against + * it — see GitHub sphere-cli#48 for the long-form motivation. + * + * `sphere trader stop` is the inverse: drive `hm.stop` for the tenant, + * then optionally tear down the local HM container if no other tenants + * remain on it. + */ + +import * as path from 'node:path'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import { + ensureLocalHM, + localHmStatus, + stopLocalHM, + type LocalHmMetadata, +} from '../host/local-hm.js'; +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'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface TraderSpawnOptions { + /** + * Desired tenant instance name. Defaults to `-trader` + * or `-trader` if the wallet has no nametag. + */ + readonly name?: string; + /** + * Trusted escrow addresses (`@nametag`, `DIRECT://hex`, or hex pubkey) + * the trader will accept as deal counterparties. Empty array → only + * the default escrow from sphere-sdk#456 (`@escrow-test-02`). + */ + readonly trustedEscrows?: ReadonlyArray; + /** Override `TRADER_SCAN_INTERVAL_MS`. Default: 30000. */ + readonly scanIntervalMs?: number; + /** + * Override the trader image. By default the local HM uses whatever + * `templates.json` specifies for the `trader-agent` template; this + * flag injects an `image` override into the hm.spawn payload. + * + * NOTE: the agentic-hosting HM doesn't yet accept image overrides + * over HMCP — this option is reserved for a follow-up issue. For now, + * override via `--templates-file` at `sphere host local-spawn`. + */ + readonly image?: string; + /** + * Test-fund spec passed via `TRADER_TEST_FUND` env. Format: + * `"coinIdHex:amount,coinIdHex:amount"`. Triggers self-mint at + * trader startup; pairs with `TRADER_FAULT_INJECTION_ALLOWED=1` + * which is set automatically here. + * + * REFUSED on mainnet — test-fund minting only makes sense on + * testnet/dev. + */ + readonly testFund?: string; + /** + * How long to wait for the local HM to be ready AND the tenant + * spawn to complete. Default: 180_000 (3 minutes). + */ + readonly readyTimeoutMs?: number; + /** + * Override the per-controller local HM data dir (and so the + * container scope). See `LocalHmConfig.baseDir`. + */ + readonly baseDir?: string; + /** Override the HM container image (see local-hm.ts DEFAULT_HM_IMAGE). */ + readonly hmImage?: string; + /** Override `templates.json` mounted into the local HM. */ + readonly templatesFile?: string; + /** Override the health-port host-side mapping for the local HM. */ + readonly healthPort?: number; + /** + * Sphere network for both wallet sanity-check and the test-fund gate. + * Defaults to whatever the wallet's `Sphere` instance reports. + */ + readonly network?: 'testnet' | 'mainnet' | 'dev'; +} + +export interface TraderSpawnResult { + readonly instance_name: string; + readonly instance_id: string; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; + readonly hm_container: string; + readonly hm_manager_address: string; + readonly trader_image_template: string; +} + +export interface TraderStopOptions { + readonly name?: string; + /** Don't tear down the local HM even if no tenants remain. */ + readonly keepHm?: boolean; + /** Per-controller local HM data dir override. */ + readonly baseDir?: string; +} + +export interface TraderStopResult { + readonly tenant_stopped: boolean; + readonly tenant_name: string | null; + readonly tenant_id: string | null; + readonly hm_stopped: boolean; + readonly hm_removed: boolean; +} + +// ============================================================================= +// Defaults / helpers +// ============================================================================= + +const DEFAULT_LOCAL_HM_BASE_DIR = './.sphere-cli/local-hm'; +const DEFAULT_READY_TIMEOUT_MS = 180_000; +const DEFAULT_TEMPLATE_ID = 'trader-agent'; + +/** + * Build the tenant instance name. Public so the unit tests can + * exercise the wallet-nametag → prefix fallback without an actual + * Sphere instance. + */ +export function deriveTenantName( + walletNametag: string | undefined, + chainPubkey: string, + explicit: string | undefined, +): string { + if (explicit && explicit.trim()) return explicit.trim(); + if (walletNametag && walletNametag.trim()) { + return `${walletNametag.trim().toLowerCase()}-trader`; + } + return `${chainPubkey.slice(0, 12).toLowerCase()}-trader`; +} + +/** + * Build the trader-tenant env bag passed to `hm.spawn`. Mirrors + * trader-service/test/e2e-live/helpers/tenant-fixture.ts:310-365 + * (`buildContainerEnv`) for the controller-facing fields: + * + * - `UNICITY_CONTROLLER_PUBKEY` so the trader's auth gate accepts + * 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` + * - 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 ...`). + * + * 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; + 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 + // together at the wrapper layer so operators don't have to know. + env['TRADER_TEST_FUND'] = opts.testFund.trim(); + env['TRADER_FAULT_INJECTION_ALLOWED'] = '1'; + } + return env; +} + +function resolveBaseDir(raw: string | undefined): string { + if (raw && raw.trim()) return path.resolve(raw.trim()); + return path.resolve(DEFAULT_LOCAL_HM_BASE_DIR); +} + +// ============================================================================= +// Spawn lifecycle +// ============================================================================= + +/** + * Bring up a local HM container (idempotent) and spawn a trader tenant + * on it. Idempotent for an already-running tenant of the same name — + * looks it up via hm.list and returns its address instead of attempting + * a re-spawn that would fail with "instance name in use." + * + * Caller owns the Sphere lifecycle. This function returns once the + * tenant is RUNNING and its address is known. + */ +export async function spawnTrader(opts: TraderSpawnOptions): Promise { + const sphere = await initSphere(); + try { + const id = sphere.identity; + if (!id) throw new Error('Wallet has no identity. Run `sphere wallet init` first.'); + + // Network gate for --test-fund. Self-mint on mainnet would burn + // real funds — refuse loudly here so the user catches the typo + // before the trader image is even pulled. + const network = opts.network ?? 'testnet'; + if (opts.testFund && network === 'mainnet') { + throw new Error( + '--test-fund is refused on mainnet. Self-mint funding is only meaningful on testnet/dev.', + ); + } + + const baseDir = resolveBaseDir(opts.baseDir); + + // ── 1. Ensure local HM is up ────────────────────────────────────── + const hmMeta = await ensureLocalHM({ + controllerPubkey: id.chainPubkey, + baseDir, + templatesFile: opts.templatesFile, + image: opts.hmImage, + healthPort: opts.healthPort, + }); + + // ── 2. Spawn (or look up existing) tenant ───────────────────────── + const instanceName = deriveTenantName(id.nametag, id.chainPubkey, opts.name); + const traderEnv = buildTraderEnv({ + controllerPubkey: id.chainPubkey, + trustedEscrows: opts.trustedEscrows, + scanIntervalMs: opts.scanIntervalMs, + testFund: opts.testFund, + network, + }); + + const result = await spawnOrAdoptTenant(sphere, hmMeta, { + instance_name: instanceName, + template_id: DEFAULT_TEMPLATE_ID, + env: traderEnv, + }, opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + + return { + ...result, + hm_container: hmMeta.containerName, + hm_manager_address: hmMeta.managerDirectAddress, + trader_image_template: DEFAULT_TEMPLATE_ID, + }; + } finally { + await safeDestroy(sphere); + } +} + +/** + * Stop a trader tenant by name, optionally tearing down the local HM + * container if no other tenants remain on it. + */ +export async function stopTrader(opts: TraderStopOptions): Promise { + const sphere = await initSphere(); + try { + const id = sphere.identity; + if (!id) throw new Error('Wallet has no identity. Run `sphere wallet init` first.'); + + const baseDir = resolveBaseDir(opts.baseDir); + const status = await localHmStatus({ controllerPubkey: id.chainPubkey, baseDir }); + if (!status.running || !status.metadata) { + // No HM means no tenants to stop. Surface a clear no-op result + // rather than a confusing "manager not reachable" timeout. + return { tenant_stopped: false, tenant_name: null, tenant_id: null, hm_stopped: false, hm_removed: false }; + } + const meta = status.metadata; + + const tenantName = deriveTenantName(id.nametag, id.chainPubkey, opts.name); + + // ── 1. Locate the tenant and stop it (best-effort over HMCP) ────── + const stopOutcome = await stopTenantBestEffort(sphere, meta, tenantName); + + // ── 2. Optionally tear down the local HM ────────────────────────── + let hmStopped = false; + let hmRemoved = false; + if (opts.keepHm !== true) { + const remaining = await listRunningTenantsBestEffort(sphere, meta); + // We just stopped tenantName, so it should no longer count. The + // HM may still be running other tenants (escrow, future ones). + // Only tear down when this was the last tenant. + const otherRunning = remaining.filter( + (entry) => entry.instance_name !== tenantName && isLiveState(entry.state), + ); + if (otherRunning.length === 0) { + const r = await stopLocalHM({ controllerPubkey: id.chainPubkey }); + hmStopped = r.stopped; + hmRemoved = r.removed; + } + } + + return { + tenant_stopped: stopOutcome.stopped, + tenant_name: stopOutcome.instance_name, + tenant_id: stopOutcome.instance_id, + hm_stopped: hmStopped, + hm_removed: hmRemoved, + }; + } finally { + await safeDestroy(sphere); + } +} + +// ============================================================================= +// HMCP plumbing — copied stylistically from host-commands.ts but kept +// internal so the public API is shaped around spawn/stop semantics. +// ============================================================================= + +interface AdoptedTenant { + readonly instance_name: string; + readonly instance_id: string; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; +} + +async function spawnOrAdoptTenant( + sphere: Sphere, + hmMeta: LocalHmMetadata, + payload: { instance_name: string; template_id: string; env: Record }, + readyTimeoutMs: number, +): Promise { + const transport = createDmTransport(sphere.communications, { + managerAddress: hmMeta.managerDirectAddress, + timeoutMs: readyTimeoutMs, + }); + try { + // Idempotency check: if a tenant with this name is already RUNNING + // on the HM, return its info instead of re-spawning. The HM rejects + // duplicate-name spawn with INVALID_PARAMS (verified in + // agentic_hosting/src/host-manager/instance-coordinator.ts); we'd + // rather give the operator the right answer than surface that. + const existing = await findRunningTenantByName(transport, payload.instance_name); + if (existing) return existing; + + // Wrap the streaming send. The HM emits hm.spawn_ack first, then + // either hm.spawn_ready or hm.spawn_failed. + return await new Promise((resolve, reject) => { + const req: HmcpRequest = createHmcpRequest('hm.spawn', payload as unknown as Record); + void transport.sendRequestStream(req, (res: HmcpResponse) => { + if (res.type === 'hm.spawn_ready') { + const p = res.payload as Record; + const tenantNametag = + typeof p['tenant_nametag'] === 'string' ? p['tenant_nametag'] : null; + if ( + typeof p['instance_id'] !== 'string' || + typeof p['instance_name'] !== 'string' || + typeof p['tenant_pubkey'] !== 'string' || + typeof p['tenant_direct_address'] !== 'string' + ) { + reject(new Error('hm.spawn_ready missing expected fields')); + return true; + } + resolve({ + instance_id: p['instance_id'] as string, + instance_name: p['instance_name'] as string, + tenant_pubkey: p['tenant_pubkey'] as string, + tenant_direct_address: p['tenant_direct_address'] as string, + tenant_nametag: tenantNametag, + }); + return true; + } + if (res.type === 'hm.spawn_failed') { + const p = res.payload as Record; + const reason = typeof p['reason'] === 'string' ? p['reason'] : 'unknown reason'; + reject(new Error(`hm.spawn failed: ${reason}`)); + return true; + } + if (res.type === 'hm.error') { + const p = res.payload as Record; + const msg = typeof p['message'] === 'string' ? p['message'] : 'HM rejected the spawn'; + reject(new Error(`hm.error: ${msg}`)); + return true; + } + // hm.spawn_ack — keep listening for the ready/failed. + return false; + }, readyTimeoutMs).catch((err) => reject(err)); + }); + } finally { + await transport.dispose().catch(() => undefined); + } +} + +async function stopTenantBestEffort( + sphere: Sphere, + hmMeta: LocalHmMetadata, + tenantName: string, +): Promise<{ stopped: boolean; instance_name: string | null; instance_id: string | null }> { + const transport = createDmTransport(sphere.communications, { + managerAddress: hmMeta.managerDirectAddress, + }); + try { + const req = createHmcpRequest('hm.stop', { instance_name: tenantName }); + const res = await transport.sendRequest(req); + if (res.type === 'hm.stop_result') { + const p = res.payload as Record; + return { + stopped: true, + instance_name: typeof p['instance_name'] === 'string' ? p['instance_name'] : tenantName, + instance_id: typeof p['instance_id'] === 'string' ? p['instance_id'] : null, + }; + } + if (res.type === 'hm.error') { + const p = res.payload as Record; + // "instance not found" is a legitimate no-op; surface it as + // stopped:false without throwing so callers can still tear + // down the HM if they want to. + const code = typeof p['error_code'] === 'string' ? p['error_code'] : ''; + if (code === 'INSTANCE_NOT_FOUND' || code === 'NOT_FOUND') { + return { stopped: false, instance_name: tenantName, instance_id: null }; + } + throw new Error(`hm.stop rejected: ${typeof p['message'] === 'string' ? p['message'] : code}`); + } + // Unknown response type — be loud rather than silently misreport. + throw new Error(`hm.stop returned unexpected response: ${res.type}`); + } catch (err) { + if (err instanceof TimeoutError) { + // HM was up at the time of `localHmStatus` but didn't respond + // to hm.stop. Treat the tenant as "stop attempted; result + // unknown" — and let the HM tear-down proceed (the HM will + // SIGTERM the container when it shuts down). + return { stopped: false, instance_name: tenantName, instance_id: null }; + } + if (err instanceof TransportError) { + return { stopped: false, instance_name: tenantName, instance_id: null }; + } + throw err; + } finally { + await transport.dispose().catch(() => undefined); + } +} + +interface InstanceListEntry { + readonly instance_name: string; + readonly state: string; +} + +async function listRunningTenantsBestEffort( + sphere: Sphere, + hmMeta: LocalHmMetadata, +): Promise { + const transport = createDmTransport(sphere.communications, { + managerAddress: hmMeta.managerDirectAddress, + }); + try { + const res = await transport.sendRequest(createHmcpRequest('hm.list', {})); + if (res.type !== 'hm.list_result') return []; + const p = res.payload as Record; + const instances = Array.isArray(p['instances']) ? p['instances'] : []; + const out: InstanceListEntry[] = []; + for (const raw of instances) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + const instance_name = typeof o['instance_name'] === 'string' ? o['instance_name'] : ''; + const state = typeof o['state'] === 'string' ? o['state'] : ''; + if (!instance_name) continue; + out.push({ instance_name, state }); + } + return out; + } catch { + return []; + } finally { + await transport.dispose().catch(() => undefined); + } +} + +async function findRunningTenantByName( + transport: DmTransport, + tenantName: string, +): Promise { + let res: HmcpResponse; + try { + res = await transport.sendRequest(createHmcpRequest('hm.list', {})); + } catch { + // hm.list timing out is not a hard failure — we'll fall through + // to a fresh spawn attempt. + return null; + } + if (res.type !== 'hm.list_result') return null; + const p = res.payload as Record; + const instances = Array.isArray(p['instances']) ? p['instances'] : []; + for (const raw of instances) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + if (o['instance_name'] !== tenantName) continue; + const state = typeof o['state'] === 'string' ? o['state'] : ''; + if (!isLiveState(state)) continue; + // Pull the tenant info from inspect — hm.list returns just summary + // fields, missing tenant_direct_address. + const inspect = await transport.sendRequest( + createHmcpRequest('hm.inspect', { instance_name: tenantName }), + ); + if (inspect.type !== 'hm.inspect_result') return null; + const ip = inspect.payload as Record; + if ( + typeof ip['instance_id'] !== 'string' || + typeof ip['tenant_pubkey'] !== 'string' || + typeof ip['tenant_direct_address'] !== 'string' + ) { + return null; + } + return { + instance_id: ip['instance_id'] as string, + instance_name: tenantName, + tenant_pubkey: ip['tenant_pubkey'] as string, + tenant_direct_address: ip['tenant_direct_address'] as string, + tenant_nametag: typeof ip['tenant_nametag'] === 'string' ? ip['tenant_nametag'] as string : null, + }; + } + return null; +} + +/** + * "Live" states for tenant adoption + reference-count-style HM + * tear-down. CREATED + BOOTING are pre-running but reserved instance + * slots — tearing down the HM under them would orphan the slot. + * STOPPED + FAILED are terminal and don't count toward "still in use." + * + * Exported for unit tests; this is a small enum but it's load-bearing + * for the keep-hm-alive decision. + */ +export function isLiveState(state: string): boolean { + const s = state.toUpperCase(); + return s === 'CREATED' || s === 'BOOTING' || s === 'RUNNING'; +} + +async function safeDestroy(sphere: Sphere): Promise { + try { await sphere.destroy(); } catch (e) { + if (process.env['DEBUG']) { + process.stderr.write(`sphere-cli: sphere.destroy error: ${String(e)}\n`); + } + } +} diff --git a/src/trader/trader-commands.test.ts b/src/trader/trader-commands.test.ts index 465b842..7569b50 100644 --- a/src/trader/trader-commands.test.ts +++ b/src/trader/trader-commands.test.ts @@ -11,6 +11,9 @@ import { resolveTenantAddress, createTraderCommand, buildCreateIntentParams, + parsePositiveInt, + parseTrustedEscrows, + buildSpawnOptions, } from './trader-commands.js'; describe('parseTimeout (trader)', () => { @@ -79,12 +82,15 @@ describe('resolveTenantAddress', () => { }); describe('createTraderCommand', () => { - it('exposes the 6 controller-scoped trader subcommands', () => { + it('exposes the 8 trader subcommands (6 ACP + spawn/stop wrappers)', () => { const trader = createTraderCommand(); const names = trader.commands.map((c) => c.name()).sort(); - // No `status` here: STATUS is system-scoped per Unicity - // architecture and routes through the host manager via HMCP. - // Use `sphere host inspect ` for trader liveness. + // ACP commands (controller→tenant): cancel-intent, create-intent, + // list-deals, list-intents, portfolio, set-strategy. `status` is + // system-scoped (routes through the HM via HMCP, not ACP) and + // doesn't appear here — use `sphere host inspect`. + // Wrapper commands (local lifecycle, no DM round-trip): + // `spawn` brings up local HM + trader tenant; `stop` tears down. expect(names).toEqual([ 'cancel-intent', 'create-intent', @@ -92,6 +98,8 @@ describe('createTraderCommand', () => { 'list-intents', 'portfolio', 'set-strategy', + 'spawn', + 'stop', ]); }); @@ -287,3 +295,96 @@ describe('buildCreateIntentParams (wire shape)', () => { expect(result.params['volume_max']).toBe(huge); }); }); + +// ============================================================================= +// `sphere trader spawn / stop` helper coverage +// ============================================================================= + +describe('parsePositiveInt', () => { + it('returns undefined for undefined input', () => { + expect(parsePositiveInt(undefined, '--x')).toBeUndefined(); + }); + + it('parses a positive integer', () => { + expect(parsePositiveInt('1500', '--x')).toBe(1500); + }); + + it('rejects zero, negative, non-numeric', () => { + expect(() => parsePositiveInt('0', '--x')).toThrow(/positive integer/); + expect(() => parsePositiveInt('-1', '--x')).toThrow(/positive integer/); + expect(() => parsePositiveInt('abc', '--x')).toThrow(/positive integer/); + }); + + it('embeds the flag name in the error', () => { + expect(() => parsePositiveInt('0', '--my-flag')).toThrow(/--my-flag/); + }); +}); + +describe('parseTrustedEscrows', () => { + it('returns undefined for undefined input', () => { + expect(parseTrustedEscrows(undefined)).toBeUndefined(); + }); + + it('splits a comma-separated list', () => { + expect(parseTrustedEscrows('@a,@b,@c')).toEqual(['@a', '@b', '@c']); + }); + + it('trims whitespace around entries', () => { + expect(parseTrustedEscrows(' @a , @b ')).toEqual(['@a', '@b']); + }); + + it('drops empty entries (so trailing comma is harmless)', () => { + expect(parseTrustedEscrows('@a,,@b,')).toEqual(['@a', '@b']); + }); + + it('returns undefined when input is entirely empty entries', () => { + expect(parseTrustedEscrows(',,, ,')).toBeUndefined(); + }); +}); + +describe('buildSpawnOptions', () => { + it('maps trivial fields through', () => { + const opts = buildSpawnOptions({ + name: 'my-trader', + hmImage: 'ghcr.io/x:y', + templatesFile: '/path/templates.json', + testFund: 'deadbeef:1000', + }); + expect(opts).toMatchObject({ + name: 'my-trader', + hmImage: 'ghcr.io/x:y', + templatesFile: '/path/templates.json', + testFund: 'deadbeef:1000', + }); + }); + + it('parses numeric flags', () => { + const opts = buildSpawnOptions({ + scanIntervalMs: '15000', + readyTimeoutMs: '60000', + healthPort: '9500', + }); + expect(opts.scanIntervalMs).toBe(15000); + expect(opts.readyTimeoutMs).toBe(60000); + expect(opts.healthPort).toBe(9500); + }); + + it('rejects invalid scan-interval-ms with a clear flag-named error', () => { + expect(() => buildSpawnOptions({ scanIntervalMs: '0' })).toThrow(/--scan-interval-ms/); + }); + + it('omits unset fields entirely (so the spawnTrader API sees minimal opts)', () => { + const opts = buildSpawnOptions({}); + expect(opts).toEqual({}); + }); + + it('passes trustedEscrows through after parsing', () => { + const opts = buildSpawnOptions({ trustedEscrows: '@x,@y' }); + expect(opts.trustedEscrows).toEqual(['@x', '@y']); + }); + + it('accepts the network override', () => { + const opts = buildSpawnOptions({ network: 'dev' }); + expect(opts.network).toBe('dev'); + }); +}); diff --git a/src/trader/trader-commands.ts b/src/trader/trader-commands.ts index 237c917..46b614a 100644 --- a/src/trader/trader-commands.ts +++ b/src/trader/trader-commands.ts @@ -21,6 +21,7 @@ import type { AcpDmTransport } from './acp-transport.js'; import type { AcpResultPayload, AcpErrorPayload } from './acp-protocols.js'; import { TimeoutError, TransportError } from '../transport/errors.js'; import { MIN_TIMEOUT_MS } from '../shared/timeout-constants.js'; +import { spawnTrader, stopTrader, type TraderSpawnOptions, type TraderStopOptions } from './spawn.js'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -65,6 +66,26 @@ interface SetStrategyOpts { trustedEscrows?: string; } +interface TraderSpawnCliOpts { + name?: string; + trustedEscrows?: string; + scanIntervalMs?: string; + testFund?: string; + readyTimeoutMs?: string; + baseDir?: string; + hmImage?: string; + templatesFile?: string; + healthPort?: string; + image?: string; + network?: 'testnet' | 'mainnet' | 'dev'; +} + +interface TraderStopCliOpts { + name?: string; + keepHm?: boolean; + baseDir?: string; +} + // ============================================================================= // Helpers // ============================================================================= @@ -360,6 +381,142 @@ async function handleSetStrategy(cmd: Command, opts: SetStrategyOpts): Promise | undefined { + if (raw === undefined) return undefined; + const items = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0); + return items.length > 0 ? items : undefined; +} + +/** + * Map CLI spawn options to the `TraderSpawnOptions` shape. Pure — + * the unit tests cover the wallet-nametag → instance-name fallback + * and the option-passthrough. + * + * Exported for unit tests. + */ +export function buildSpawnOptions(opts: TraderSpawnCliOpts): TraderSpawnOptions { + const out: Record = {}; + if (opts.name) out['name'] = opts.name; + const escrows = parseTrustedEscrows(opts.trustedEscrows); + if (escrows) out['trustedEscrows'] = escrows; + const scan = parsePositiveInt(opts.scanIntervalMs, '--scan-interval-ms'); + if (scan !== undefined) out['scanIntervalMs'] = scan; + if (opts.testFund) out['testFund'] = opts.testFund; + const ready = parsePositiveInt(opts.readyTimeoutMs, '--ready-timeout-ms'); + if (ready !== undefined) out['readyTimeoutMs'] = ready; + if (opts.baseDir) out['baseDir'] = opts.baseDir; + if (opts.hmImage) out['hmImage'] = opts.hmImage; + if (opts.templatesFile) out['templatesFile'] = opts.templatesFile; + const health = parsePositiveInt(opts.healthPort, '--health-port'); + if (health !== undefined) out['healthPort'] = health; + if (opts.image) out['image'] = opts.image; + if (opts.network) out['network'] = opts.network; + return out as TraderSpawnOptions; +} + +async function handleSpawn(cmd: Command, opts: TraderSpawnCliOpts): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + let spawnOpts: TraderSpawnOptions; + try { + spawnOpts = buildSpawnOptions(opts); + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + try { + const result = await spawnTrader(spawnOpts); + if (json) { + printJson(result); + } else { + process.stdout.write( + `Trader tenant ready:\n` + + ` instance_name: ${result.instance_name}\n` + + ` instance_id: ${result.instance_id}\n` + + ` tenant_direct_address: ${result.tenant_direct_address}\n` + + ` tenant_nametag: ${result.tenant_nametag ?? '(none)'}\n` + + ` hm_container: ${result.hm_container}\n` + + ` hm_manager_address: ${result.hm_manager_address}\n\n` + + `Use this address for subsequent ACP calls:\n` + + ` export SPHERE_TRADER_TENANT='${result.tenant_direct_address}'\n` + + ` sphere trader create-intent --direction sell --base UCT --quote USDU \\\n` + + ` --rate-min 1 --rate-max 1 --volume-min 1 --volume-max 100\n`, + ); + } + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + } +} + +async function handleStop(cmd: Command, opts: TraderStopCliOpts): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + const stopOpts: TraderStopOptions = {}; + const out = stopOpts as { name?: string; keepHm?: boolean; baseDir?: string }; + if (opts.name) out.name = opts.name; + if (opts.keepHm) out.keepHm = true; + if (opts.baseDir) out.baseDir = opts.baseDir; + try { + const result = await stopTrader(stopOpts); + if (json) { + printJson(result); + } else { + if (result.tenant_stopped) { + process.stdout.write(`Trader tenant stopped: ${result.tenant_name ?? '(unknown)'}\n`); + } else if (result.tenant_name) { + process.stdout.write(`No live trader tenant '${result.tenant_name}' found.\n`); + } else { + process.stdout.write('No local HM running for this wallet.\n'); + } + if (result.hm_stopped) { + process.stdout.write( + result.hm_removed + ? 'Local HM stopped and removed.\n' + : 'Local HM stopped (container kept).\n', + ); + } else if (opts.keepHm) { + process.stdout.write('Local HM left running (--keep-hm).\n'); + } + } + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + } +} + // ============================================================================= // Command tree // ============================================================================= @@ -439,6 +596,39 @@ export function createTraderCommand(): Command { await handleSetStrategy(this, opts); }); + // ── Local tenant lifecycle (per-user HM + trader) ───────────────── + // `sphere trader spawn` and `sphere trader stop` don't talk to a + // running trader (no --tenant flag) — they bring up / tear down the + // per-user local HM + trader tenant pair. See sphere-cli#48 for the + // motivation. + + trader + .command('spawn') + .description('Bring up a local trader tenant (and its HM) for the current wallet') + .option('--name ', 'Tenant instance name (default: -trader)') + .option('--trusted-escrows ', 'Comma-separated escrow addresses for the trader') + .option('--scan-interval-ms ', 'Override TRADER_SCAN_INTERVAL_MS (default: 30000)') + .option('--test-fund ', 'Self-mint test funds at startup, e.g. ":,..." (testnet only)') + .option('--ready-timeout-ms ', 'Wait up to this long for HM + tenant ready (default: 180000)') + .option('--base-dir ', 'Override per-controller data dir (default: ./.sphere-cli/local-hm)') + .option('--hm-image ', 'Override the local HM container image') + .option('--templates-file ', 'Override templates.json mounted into the HM') + .option('--health-port ', 'Override HM health-port host mapping (127.0.0.1:)') + .option('--network ', 'Sphere network: testnet|mainnet|dev (default: testnet)') + .action(async function (this: Command, opts: TraderSpawnCliOpts) { + await handleSpawn(this, opts); + }); + + trader + .command('stop') + .description('Stop the local trader tenant (and the HM if no other tenants remain)') + .option('--name ', 'Tenant instance name to stop (default: -trader)') + .option('--keep-hm', 'Leave the local HM container running after stopping the tenant') + .option('--base-dir ', 'Override per-controller data dir (default: ./.sphere-cli/local-hm)') + .action(async function (this: Command, opts: TraderStopCliOpts) { + await handleStop(this, opts); + }); + // Attach the shared-options help text to every subcommand. for (const sub of trader.commands) { sub.addHelpText('after', `\n${inheritedHelp}`);