From b19b2960f0a45471e8b1052a447a84dde6abfdd4 Mon Sep 17 00:00:00 2001 From: Genie Date: Wed, 29 Apr 2026 17:15:13 -0300 Subject: [PATCH] =?UTF-8?q?feat(omni):=20genie=20omni=20handshake=20?= =?UTF-8?q?=E2=80=94=20register=20host=20with=20ed25519=20keypair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 2 of the omni-host-fingerprint-trust wish (D5 follow-up). First genie-side piece of per-host fingerprint trust: generate a local ed25519 keypair, register the public key with the local omni server via POST /api/v2/trust/handshake, and persist the returned host_id locally so subsequent groups (request signing, verification) can attach `X-Genie-Host-Id` to outgoing requests. Builds on omni #555/#556/#558 (the schema + handshake endpoint + trust CRUD endpoints). CLI surface =========== genie omni handshake One-time registration (idempotent on pubkey) genie omni handshake --rotate New keypair + revoke old in a single round-trip genie omni handshake --hostname X Override os.hostname() for the omni record Files written ============= ~/.genie/keys/genie-host.ed25519 PKCS#8 PEM, 0600 perms (private) ~/.genie/keys/genie-host.ed25519.pub base64url of raw 32-byte pubkey ~/.genie/keys/host.json { hostId, pubkey, hostname, registeredAt, rotatedFrom? } Sanity checks ============= - Refuses to write keys inside a git working tree (`assertNotInsideGitRepo`) so an accidental `genie omni handshake` from a project root doesn't stage the secret key for the next commit. Walk up to fs root or 16 levels, whichever comes first. - `--rotate` requires an existing host record. Generates the new keypair, registers it, then revokes the OLD record. Order matters: revoke fails after register, so we never lose access. If revoke fails post-register, the new key is live and we surface the manual recovery command. Auth: bearer token from genie config or $OMNI_API_KEY. The first handshake always uses bearer because that's the only way to bootstrap trust for a brand-new host. Subsequent signed requests (Group 3) can authenticate themselves. What's NOT in ============= - Signing outgoing requests (Group 3): the keypair lives here, but `omni-registration.ts` doesn't read it yet. - Verification middleware on omni (Group 4, security review gate): the host record is stored, but no incoming request is verified yet. Tests ===== 9 tests pinning: - keyPaths respects $GENIE_HOME (test isolation) - assertNotInsideGitRepo throws on git tree, passes on plain dir - generateAndPersistKeypair → 0600 perms + 43-char base64url pubkey - host.json round-trip (load null, write/load, malformed → null) - regenerating overwrites the keypair The HTTP path is exercised by the omni-side tests in #556/#558 — we don't re-test the omni contract here, just the local filesystem invariants. Tracked under omni-host-fingerprint-trust wish, Group 2. --- skills/omni/SKILL.md | 12 + src/genie.ts | 2 + src/term-commands/omni/handshake.test.ts | 125 ++++++++++ src/term-commands/omni/handshake.ts | 282 +++++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 src/term-commands/omni/handshake.test.ts create mode 100644 src/term-commands/omni/handshake.ts diff --git a/skills/omni/SKILL.md b/skills/omni/SKILL.md index af8f93a6e..e8e4b6976 100644 --- a/skills/omni/SKILL.md +++ b/skills/omni/SKILL.md @@ -3,6 +3,18 @@ name: omni description: "Wire a Genie agent to an Omni channel in one canonical flow — register the agent, bind to an instance, verify the round-trip. Replaces the 5+ command legacy chain." allowed-tools: Bash(omni *), Bash(genie *) --- + + # /genie:omni — Canonical Genie ↔ Omni Wiring diff --git a/src/genie.ts b/src/genie.ts index 09f4a2173..9b54fc2bf 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -62,6 +62,7 @@ import { type LogOptions, logCommand } from './term-commands/log.js'; import { registerMetricsCommands } from './term-commands/metrics.js'; import { registerSendInboxCommands } from './term-commands/msg.js'; import { registerNotifyCommands } from './term-commands/notify.js'; +import { registerOmniNamespace } from './term-commands/omni/handshake.js'; import * as orchestrateCmd from './term-commands/orchestrate.js'; import { registerProjectCommands } from './term-commands/project.js'; import { registerPruneCommands } from './term-commands/prune.js'; @@ -235,6 +236,7 @@ registerInitCommands(program); registerTeamNamespace(program); registerDirNamespace(program); registerAgentCommands(program); +registerOmniNamespace(program); registerSendInboxCommands(program); registerStateCommands(program); registerDispatchCommands(program); diff --git a/src/term-commands/omni/handshake.test.ts b/src/term-commands/omni/handshake.test.ts new file mode 100644 index 000000000..b7b71b3ca --- /dev/null +++ b/src/term-commands/omni/handshake.test.ts @@ -0,0 +1,125 @@ +/** + * Unit tests for `genie omni handshake` keypair + filesystem helpers. + * + * Covers: + * - keyPaths() respects $GENIE_HOME so tests can isolate. + * - assertNotInsideGitRepo throws when the target path lives under a .git dir. + * - generateAndPersistKeypair creates 0600 perms on the private key and + * writes a base64url-encoded 32-byte public key (44 chars unpadded + * since 32 bytes / 3 * 4 = 42.67 → 43 chars + maybe padding). + * - loadHostJson + writeHostJson round-trip a HostRecord. + * + * The HTTP path (callTrustEndpoint → omni's POST /trust/handshake) is + * exercised indirectly by the omni-side endpoint tests in + * automagik-dev/omni#556 and #558. We don't re-test the omni contract + * here; we just pin the local filesystem invariants. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { execSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { __test__ } from './handshake'; + +const ORIGINAL_GENIE_HOME = process.env.GENIE_HOME; +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'genie-handshake-test-')); + process.env.GENIE_HOME = workDir; +}); + +afterEach(() => { + if (ORIGINAL_GENIE_HOME === undefined) { + process.env.GENIE_HOME = undefined; + } else { + process.env.GENIE_HOME = ORIGINAL_GENIE_HOME; + } + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('keyPaths', () => { + test('respects $GENIE_HOME', () => { + const paths = __test__.keyPaths(); + expect(paths.dir).toBe(join(workDir, 'keys')); + expect(paths.privateKey).toBe(join(workDir, 'keys', 'genie-host.ed25519')); + expect(paths.publicKey).toBe(join(workDir, 'keys', 'genie-host.ed25519.pub')); + expect(paths.hostJson).toBe(join(workDir, 'keys', 'host.json')); + }); +}); + +describe('assertNotInsideGitRepo', () => { + test('throws when the path lives inside a git working tree', () => { + // Bare init avoids needing user.name/email config. + execSync(`git -C ${workDir} init --quiet`); + const inside = join(workDir, 'subdir', 'keys'); + expect(() => __test__.assertNotInsideGitRepo(inside)).toThrow(/git working tree/i); + }); + + test('does not throw when the path is outside any git tree', () => { + // workDir is a fresh tmpdir with no .git; no parent has one either + // (assuming /tmp isn't itself a git repo, which it isn't on real systems). + const outside = join(workDir, 'keys'); + expect(() => __test__.assertNotInsideGitRepo(outside)).not.toThrow(); + }); +}); + +describe('generateAndPersistKeypair', () => { + test('writes the private key with 0600 perms', () => { + const paths = __test__.keyPaths(); + mkdirSync(paths.dir, { recursive: true }); + const { pubkeyB64Url } = __test__.generateAndPersistKeypair(paths); + + const privStat = statSync(paths.privateKey); + // Mask out the file-type bits and assert the perm bits. + expect(privStat.mode & 0o777).toBe(0o600); + + expect(pubkeyB64Url).toMatch(/^[A-Za-z0-9_-]{43}=?$/); + }); + + test('public key file matches the returned base64url', () => { + const paths = __test__.keyPaths(); + mkdirSync(paths.dir, { recursive: true }); + const { pubkeyB64Url } = __test__.generateAndPersistKeypair(paths); + const onDisk = readFileSync(paths.publicKey, 'utf-8').trim(); + expect(onDisk).toBe(pubkeyB64Url); + }); + + test('regenerating overwrites the keypair', () => { + const paths = __test__.keyPaths(); + mkdirSync(paths.dir, { recursive: true }); + const first = __test__.generateAndPersistKeypair(paths); + const second = __test__.generateAndPersistKeypair(paths); + // ed25519 keys are 32 random bytes — collision odds are astronomically low. + expect(second.pubkeyB64Url).not.toBe(first.pubkeyB64Url); + }); +}); + +describe('host.json round-trip', () => { + test('loadHostJson returns null when missing', () => { + const paths = __test__.keyPaths(); + expect(__test__.loadHostJson(paths)).toBeNull(); + }); + + test('write then load returns the same record', () => { + const paths = __test__.keyPaths(); + mkdirSync(paths.dir, { recursive: true }); + const record = { + hostId: 'host-uuid-1', + pubkey: 'A'.repeat(43), + hostname: 'genie.local', + registeredAt: new Date().toISOString(), + }; + __test__.writeHostJson(paths, record); + const loaded = __test__.loadHostJson(paths); + expect(loaded).toEqual(record); + }); + + test('loadHostJson returns null when the file is malformed JSON', () => { + const paths = __test__.keyPaths(); + mkdirSync(paths.dir, { recursive: true }); + writeFileSync(paths.hostJson, 'not json at all'); + expect(__test__.loadHostJson(paths)).toBeNull(); + }); +}); diff --git a/src/term-commands/omni/handshake.ts b/src/term-commands/omni/handshake.ts new file mode 100644 index 000000000..0f51d5f68 --- /dev/null +++ b/src/term-commands/omni/handshake.ts @@ -0,0 +1,282 @@ +/** + * `genie omni handshake` — register this genie host with the local omni + * server using a per-host ed25519 keypair. + * + * Wish: omni-host-fingerprint-trust, Group 2. + * + * Flow: + * 1. Find or generate a keypair at ~/.genie/keys/genie-host.{ed25519,ed25519.pub} + * (perms 0600; refuse to write keys inside a git working tree). + * 2. POST { pubkey, hostname, capabilities } to omni's + * /api/v2/trust/handshake (which is idempotent on pubkey). + * 3. Persist the returned host_id to ~/.genie/keys/host.json so the + * signing middleware (Group 3) can attach `X-Genie-Host-Id` to every + * outgoing request. + * 4. `--rotate` issues a fresh keypair and revokes the old in a single + * round-trip (atomically from the operator's perspective — the old + * key is revoked AFTER the new one registers, so we never lose + * access). + * + * Auth: bearer token from genie config / $OMNI_API_KEY. The first + * handshake always uses bearer because that's the only way to bootstrap + * trust for a brand-new host. Subsequent handshakes (and the eventual + * Group 3 signing path) can use signatures. + * + * What's NOT in this command (subsequent groups): + * - Signing outgoing requests (Group 3): the keypair lands here, but + * `omni-registration.ts` doesn't read it yet. + * - Verification middleware on omni (Group 4): the host record is + * stored, but no request is verified yet. + */ + +import { execSync } from 'node:child_process'; +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { hostname as osHostname } from 'node:os'; +import { dirname, join, resolve as resolvePath } from 'node:path'; +import type { Command } from 'commander'; +import { loadGenieConfig } from '../../lib/genie-config.js'; +import { resolveOmniApiUrl } from '../../lib/omni-registration.js'; + +interface KeyPaths { + dir: string; + privateKey: string; + publicKey: string; + hostJson: string; +} + +interface HostRecord { + hostId: string; + pubkey: string; + hostname: string; + registeredAt: string; + rotatedFrom?: string; +} + +function keyPaths(): KeyPaths { + const home = process.env.GENIE_HOME ?? join(process.env.HOME ?? '/root', '.genie'); + const dir = join(home, 'keys'); + return { + dir, + privateKey: join(dir, 'genie-host.ed25519'), + publicKey: join(dir, 'genie-host.ed25519.pub'), + hostJson: join(dir, 'host.json'), + }; +} + +/** + * Refuse to write the keypair inside a git working tree. Operators that + * accidentally `genie omni handshake` from a project root would otherwise + * stage their secret key for the next commit. This is a sanity check; not + * a security guarantee — operators with custom GENIE_HOME values are on + * their own. + */ +function assertNotInsideGitRepo(dir: string): void { + let probe = resolvePath(dir); + for (let depth = 0; depth < 16; depth++) { + if (existsSync(join(probe, '.git'))) { + throw new Error( + `Refusing to write keys to ${dir} — it lives inside a git working tree (${join(probe, '.git')}). Set $GENIE_HOME to a path outside any git repo and re-run.`, + ); + } + const parent = dirname(probe); + if (parent === probe) return; // hit fs root + probe = parent; + } +} + +function generateAndPersistKeypair(paths: KeyPaths): { pubkeyB64Url: string } { + if (!existsSync(paths.dir)) { + mkdirSync(paths.dir, { recursive: true, mode: 0o700 }); + } + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + // Raw 32-byte public key, base64url encoded (matches omni's regex gate). + const rawPub = publicKey.export({ format: 'der', type: 'spki' }); + // The DER prefix for ed25519 is 12 bytes; the last 32 bytes are the raw key. + const rawKey = rawPub.subarray(rawPub.length - 32); + const pubkeyB64Url = rawKey.toString('base64url'); + + writeFileSync(paths.privateKey, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 }); + writeFileSync(paths.publicKey, pubkeyB64Url, { mode: 0o644 }); + // Belt-and-suspenders chmod in case writeFileSync's mode flag was ignored + chmodSync(paths.privateKey, 0o600); + + return { pubkeyB64Url }; +} + +function loadExistingPubkey(paths: KeyPaths): string | null { + if (!existsSync(paths.publicKey)) return null; + return readFileSync(paths.publicKey, 'utf-8').trim(); +} + +function loadHostJson(paths: KeyPaths): HostRecord | null { + if (!existsSync(paths.hostJson)) return null; + try { + return JSON.parse(readFileSync(paths.hostJson, 'utf-8')) as HostRecord; + } catch { + return null; + } +} + +function writeHostJson(paths: KeyPaths, record: HostRecord): void { + writeFileSync(paths.hostJson, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o644 }); +} + +async function resolveOmniApiKey(): Promise { + const envKey = process.env.OMNI_API_KEY; + if (envKey) return envKey; + const config = await loadGenieConfig(); + return config.omni?.apiKey; +} + +async function callTrustEndpoint( + apiUrl: string, + apiKey: string | undefined, + method: string, + path: string, + body?: unknown, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const res = await fetch(`${apiUrl.replace(/\/+$/, '')}/api/v2/trust${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`omni trust ${method} ${path}: HTTP ${res.status}${text ? ` — ${text}` : ''}`); + } + return (await res.json()) as T; +} + +interface TrustHostResponse { + data: { id: string; pubkey: string; hostname: string }; +} + +/** Detect the parent process is a TTY (so we know whether to inject color). */ +function gitRevParseSafe(): string | undefined { + try { + return execSync('git rev-parse --git-dir', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return undefined; + } +} + +interface HandshakeOptions { + rotate?: boolean; + hostname?: string; +} + +async function handleHandshake(options: HandshakeOptions): Promise { + const apiUrl = await resolveOmniApiUrl(); + if (!apiUrl) { + throw new Error( + 'Omni is not configured. Set OMNI_API_URL or `omni.apiUrl` in your genie config first.\nExample: omni install', + ); + } + const apiKey = await resolveOmniApiKey(); + if (!apiKey) { + throw new Error('Omni API key not configured. Set OMNI_API_KEY or `omni.apiKey` in your genie config.'); + } + + const paths = keyPaths(); + assertNotInsideGitRepo(paths.dir); + + const previousRecord = loadHostJson(paths); + let pubkey = loadExistingPubkey(paths); + + if (options.rotate) { + if (!previousRecord) { + throw new Error( + 'Cannot --rotate: no existing host record at ~/.genie/keys/host.json. Run a plain handshake first.', + ); + } + // Generate fresh keypair (overwrites old keys on disk). + const fresh = generateAndPersistKeypair(paths); + pubkey = fresh.pubkeyB64Url; + } else if (!pubkey) { + const fresh = generateAndPersistKeypair(paths); + pubkey = fresh.pubkeyB64Url; + } + + const hostname = options.hostname ?? previousRecord?.hostname ?? osHostname() ?? 'unknown-host'; + const capabilities = { + genieVersion: process.env.GENIE_VERSION ?? 'unknown', + platform: process.platform, + nodeVersion: process.version, + }; + + const { data: host } = await callTrustEndpoint(apiUrl, apiKey, 'POST', '/handshake', { + pubkey, + hostname, + capabilities, + }); + + const newRecord: HostRecord = { + hostId: host.id, + pubkey: host.pubkey, + hostname: host.hostname, + registeredAt: new Date().toISOString(), + ...(options.rotate && previousRecord ? { rotatedFrom: previousRecord.hostId } : {}), + }; + writeHostJson(paths, newRecord); + + // Step 4 of the rotate flow: revoke the OLD host record AFTER the new one + // registers. Order matters — if revoke fails, we still have a working new + // host_id; if revoke succeeds before register, we'd have lost access. + if (options.rotate && previousRecord && previousRecord.hostId !== host.id) { + try { + await callTrustEndpoint<{ data: unknown }>(apiUrl, apiKey, 'DELETE', `/hosts/${previousRecord.hostId}`); + } catch (err) { + // Non-fatal — operator can finish revoking via `omni trust revoke ` + // if needed. Surface the manual recovery path explicitly. + const message = err instanceof Error ? err.message : String(err); + console.warn( + `\n⚠ Rotated key registered as ${host.id}, but revoking the old host (${previousRecord.hostId}) failed:\n ${message}\n Finish manually: omni trust revoke ${previousRecord.hostId}\n`, + ); + } + } + + console.log(`Genie host registered: ${host.id}`); + console.log(` Hostname: ${host.hostname}`); + console.log(` Public key: ${host.pubkey}`); + console.log(` Private key: ${paths.privateKey} (perms 0600)`); + if (options.rotate && previousRecord) { + console.log(` Rotated from: ${previousRecord.hostId} (revoked)`); + } +} + +export function registerOmniNamespace(program: Command): void { + // Avoid clobbering an existing `omni` command if one was registered earlier + // in startup. Commander throws on duplicate command names, but + // `program.commands.find` is the cheap pre-check. + const existing = program.commands.find((c) => c.name() === 'omni'); + const omni = existing ?? program.command('omni').description('Omni integration commands (handshake, etc.)'); + + omni + .command('handshake') + .description('Register this genie host with the local omni server (ed25519 keypair, idempotent).') + .option('--rotate', 'Issue a new keypair and revoke the existing host record') + .option('--hostname ', 'Override the hostname reported to omni (defaults to os.hostname())') + .action(async (options: HandshakeOptions) => { + try { + await handleHandshake(options); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`Error: ${message}`); + process.exit(1); + } + }); +} + +// Exported for tests to exercise without driving Commander. +export const __test__ = { + keyPaths, + assertNotInsideGitRepo, + loadExistingPubkey, + loadHostJson, + writeHostJson, + generateAndPersistKeypair, + gitRevParseSafe, +};