Harness core (#1110)

* chore: scaffold @honcho-ai/harness-core

* feat(harness-core): resolve shared root config

* feat(harness-core): send client identity headers on SDK requests

* feat(harness-core): drop cloud vs custom api header

* feat(harness-core): migrating v0 config to schema v1 on read

* chore(harness-core): clean up

* feat(config): describe oauth and host overrides in the v1 schema

* chore: rename to harness-plugin-core

* feat(harness-plugin-core): update telemetry headers on a live client.
This commit is contained in:
ajspig 2026-09-02 17:56:09 -04:00 committed by GitHub
parent 55a0519bd2
commit b573a84806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 653 additions and 0 deletions

2
harness-plugin-core/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,10 @@
# Changelog
All notable changes to `@honcho-ai/harness-plugin-core` will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
This package versions independently of the Honcho API, `@honcho-ai/sdk`, and host plugins.
## [Unreleased]

View File

@ -0,0 +1,70 @@
# @honcho-ai/harness-plugin-core
Shared runtime for Honcho harness plugins.
```ts
import { loadConfig, resolveConfig } from '@honcho-ai/harness-plugin-core'
const cfg = loadConfig({ host: 'harness' })
// a harness can pass its plugin config as an overlay of the same six keys:
const cfg = resolveConfig(file, { host: 'harness', overlay: { workspace: 'harness', auth: { apiKey } } })
```
Locally: `"@honcho-ai/harness-plugin-core": "file:../harness-plugin-core"` (bun imports the TypeScript source).
## File shape
```json
{
"schemaVersion": 1,
"peerName": "user",
"workspace": "honcho",
"baseUrl": "https://api.honcho.dev",
"timeoutMs": 30000,
"auth": { "apiKey": "${HONCHO_API_KEY}" },
"enabled": true,
"hosts": {
"test": { "workspace": "test" }
}
}
```
Missing `schemaVersion` is 0. On read, v0 keys (`environmentUrl`, `workspaceId`, top-level `apiKey`) are remapped in memory; the file is not rewritten.
Resolution, highest wins: `HONCHO_*` env → overlay → `hosts.<host>` → root → built-in.
A host block may override the same six fields.
Built-ins: `baseUrl = https://api.honcho.dev`, `timeoutMs = 30000`, `enabled = true`, `peerName = $USER`, `workspace` falls back to the host name. The SDK pins `/v3`; config stores the origin.
## Telemetry headers
Pass `telemetryHeaders()` as the SDK's `defaultHeaders`. Arbitrary headers are accepted by both the SDK and the Honcho API; missing identity fields are omitted.
| Header | Meaning | Example |
|---|---|---|
| `X-Honcho-Host` | Agent host name, or `name/version` | `harness/1.3.13` |
| `X-Honcho-Plugin` | Honcho plugin version | `0.1.3` |
| `X-Honcho-Runtime` | This package's version (always sent) | `0.1.0` |
| `X-Honcho-Agent-Model` | The agent's completion model, not a Honcho model | `claude-sonnet-4-5` |
```ts
import { Honcho } from '@honcho-ai/sdk'
import { loadConfig, setTelemetryHeaders, telemetryHeaders } from '@honcho-ai/harness-plugin-core'
const cfg = loadConfig({ host: 'harness' })
const honcho = new Honcho({
apiKey: cfg.apiKey,
baseURL: cfg.baseUrl,
workspaceId: cfg.workspace,
timeout: cfg.timeoutMs,
defaultHeaders: telemetryHeaders({
host: 'harness',
hostVersion: '1.3.13',
pluginVersion: '0.1.3',
model: 'claude-sonnet-4-5',
}),
})
setTelemetryHeaders(honcho.http.defaultHeaders, { model: 'claude-opus-4' })
```

View File

@ -0,0 +1,25 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@honcho-ai/harness-plugin-core",
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@ -0,0 +1,31 @@
{
"name": "@honcho-ai/harness-plugin-core",
"version": "0.1.0",
"description": "Shared runtime for Honcho harness plugins",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "MIT",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"files": [
"src",
"README.md",
"CHANGELOG.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/plastic-labs/honcho.git",
"directory": "harness-plugin-core"
},
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.0.1",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,239 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
export interface AuthConfig {
apiKey?: string
oauth?: { accessToken?: string; refreshToken?: string; expiresAt?: string }
}
/** Identity + connection + kill switch. Valid at root and as a host override. */
export interface RootConfig {
peerName?: string
workspace?: string
baseUrl?: string
timeoutMs?: number
auth?: AuthConfig
enabled?: boolean
}
export type HostBlock = RootConfig
export interface FileConfig extends RootConfig {
schemaVersion?: number
hosts?: Record<string, HostBlock>
}
export interface ResolvedConfig {
host: string
peerName: string
workspace: string
baseUrl: string
timeoutMs: number
auth: AuthConfig
apiKey?: string
enabled: boolean
warnings: string[]
}
export const DEFAULT_BASE_URL = 'https://api.honcho.dev'
export const DEFAULT_TIMEOUT_MS = 30_000
export const CONFIG_SCHEMA_VERSION = 1
function isObj(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v)
}
/** Pre-schema files (no schemaVersion) → v1 keys. Host blocks included. */
function migrate(file: unknown): Record<string, unknown> {
if (!isObj(file)) return {}
const v = file.schemaVersion
if (typeof v === 'number' && v >= CONFIG_SCHEMA_VERSION) return { ...file }
const out: Record<string, unknown> = { ...file }
const blocks: Record<string, unknown>[] = [out]
if (isObj(out.hosts)) {
out.hosts = Object.fromEntries(
Object.entries(out.hosts).map(([k, block]) => {
if (!isObj(block)) return [k, block]
const next = { ...block }
blocks.push(next)
return [k, next]
})
)
}
for (const b of blocks) {
if (typeof b.baseUrl !== 'string') {
if (typeof b.environmentUrl === 'string') b.baseUrl = b.environmentUrl
else if (isObj(b.endpoint) && typeof b.endpoint.baseUrl === 'string') {
b.baseUrl = b.endpoint.baseUrl
}
}
if (typeof b.workspace !== 'string' && typeof b.workspaceId === 'string') {
b.workspace = b.workspaceId
}
const auth: Record<string, unknown> = isObj(b.auth) ? { ...b.auth } : {}
if (typeof auth.apiKey !== 'string' && typeof b.apiKey === 'string') auth.apiKey = b.apiKey
if (!isObj(auth.oauth) && isObj(b.oauth)) auth.oauth = b.oauth
if (Object.keys(auth).length) b.auth = auth
delete b.environmentUrl
delete b.endpoint
delete b.workspaceId
delete b.apiKey
delete b.oauth
}
out.schemaVersion = 1
return out
}
function merge<T>(base: T, over: unknown): T {
if (over === undefined || over === null) return base
if (Array.isArray(over) || !isObj(over)) return over as T
const out: Record<string, unknown> = { ...(isObj(base) ? base : {}) }
for (const [k, v] of Object.entries(over)) {
if (v !== undefined) out[k] = k in out ? merge(out[k], v) : v
}
return out as T
}
/** Make a value safe to pass to the SDK as `baseURL`. */
export function normalizeBaseUrl(input: string): string {
let s = input.trim()
if (!s) return s
if (!s.startsWith('http://') && !s.startsWith('https://')) {
const host = s.split('/')[0].split(':')[0].toLowerCase()
const local = host === 'localhost' || host === '127.0.0.1' || host === '::1'
s = `${local ? 'http' : 'https'}://${s}`
}
try {
const u = new URL(s)
u.hostname = u.hostname.toLowerCase()
const path = u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, '')
return `${u.protocol}//${u.host}${path}`
} catch {
return s
}
}
function interpolate(value: string, env: NodeJS.Dict<string>, warnings: string[]): string {
return value.replace(/\$\{([^}]+)\}/g, (m, name: string) => {
const v = env[name]
if (!v) {
warnings.push(`${m} is not set`)
return m
}
return v
})
}
function walkStrings<T>(value: T, fn: (s: string) => string): T {
if (typeof value === 'string') return fn(value) as T
if (Array.isArray(value)) return value.map((x) => walkStrings(x, fn)) as T
if (isObj(value)) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = walkStrings(v, fn)
return out as T
}
return value
}
/** Pull only the six root fields. Extra host keys (injection, observation, …) are ignored. */
function pickRoot(block: unknown): RootConfig {
if (!isObj(block)) return {}
const auth: AuthConfig = isObj(block.auth) ? { ...(block.auth as AuthConfig) } : {}
const out: RootConfig = {}
if (typeof block.peerName === 'string') out.peerName = block.peerName
if (typeof block.workspace === 'string') out.workspace = block.workspace
if (typeof block.baseUrl === 'string') out.baseUrl = block.baseUrl
if (typeof block.timeoutMs === 'number') out.timeoutMs = block.timeoutMs
if (Object.keys(auth).length) out.auth = auth
if (typeof block.enabled === 'boolean') out.enabled = block.enabled
return out
}
function pickHost(hosts: Record<string, unknown> | undefined, name: string): RootConfig {
if (!hosts || !isObj(hosts[name])) return {}
return pickRoot(hosts[name])
}
/**
* Highest wins: HONCHO_* env overlay hosts.<host> root built-in.
*/
export function resolveConfig(
file: unknown,
opts: { host: string; env?: NodeJS.Dict<string>; overlay?: RootConfig }
): ResolvedConfig {
const warnings: string[] = []
const env = opts.env ?? process.env
const host = opts.host
const raw = migrate(file)
if (typeof raw.schemaVersion === 'number' && raw.schemaVersion > CONFIG_SCHEMA_VERSION) {
warnings.push(`config schemaVersion ${raw.schemaVersion} is newer than ${CONFIG_SCHEMA_VERSION}`)
}
const hosts = isObj(raw.hosts) ? raw.hosts : undefined
let acc: RootConfig = {
baseUrl: DEFAULT_BASE_URL,
timeoutMs: DEFAULT_TIMEOUT_MS,
enabled: true,
workspace: host,
}
acc = merge(acc, pickRoot(raw))
acc = merge(acc, pickHost(hosts, host))
acc = merge(acc, pickRoot(opts.overlay))
if (env.HONCHO_API_KEY) {
if (acc.auth?.apiKey) warnings.push('HONCHO_API_KEY shadows auth.apiKey')
acc = merge(acc, { auth: { apiKey: env.HONCHO_API_KEY } })
}
if (env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT) {
const token = env.HONCHO_BASE_URL || env.HONCHO_URL || env.HONCHO_ENDPOINT || ''
acc.baseUrl = token === 'local' ? 'http://localhost:8000' : token
}
if (env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID) {
acc.workspace = env.HONCHO_WORKSPACE || env.HONCHO_WORKSPACE_ID
}
if (env.HONCHO_PEER_NAME) acc.peerName = env.HONCHO_PEER_NAME
if (env.HONCHO_TIMEOUT_MS) {
const n = Number(env.HONCHO_TIMEOUT_MS)
if (Number.isFinite(n) && n > 0) acc.timeoutMs = n
}
if (env.HONCHO_ENABLED === 'false') acc.enabled = false
acc = walkStrings(acc, (s) => interpolate(s, env, warnings))
if (acc.baseUrl) acc.baseUrl = normalizeBaseUrl(acc.baseUrl)
const auth = acc.auth ?? {}
return {
host,
peerName: acc.peerName || env.USER || env.USERNAME || 'user',
workspace: acc.workspace || host,
baseUrl: acc.baseUrl || DEFAULT_BASE_URL,
timeoutMs: acc.timeoutMs && acc.timeoutMs > 0 ? acc.timeoutMs : DEFAULT_TIMEOUT_MS,
auth,
apiKey: auth.apiKey,
enabled: acc.enabled !== false,
warnings,
}
}
export function configPath(env: NodeJS.Dict<string> = process.env): string {
return env.HONCHO_CONFIG_PATH || join(homedir(), '.honcho', 'config.json')
}
export function loadConfig(opts: {
host: string
env?: NodeJS.Dict<string>
overlay?: RootConfig
}): ResolvedConfig {
const env = opts.env ?? process.env
const path = configPath(env)
let file: unknown = {}
if (existsSync(path)) {
try {
file = JSON.parse(readFileSync(path, 'utf-8'))
} catch {
file = {}
}
}
return resolveConfig(file, { ...opts, env })
}

View File

@ -0,0 +1,29 @@
export const version = '0.1.0'
export {
configPath,
loadConfig,
normalizeBaseUrl,
resolveConfig,
DEFAULT_BASE_URL,
DEFAULT_TIMEOUT_MS,
} from './config.ts'
export type {
AuthConfig,
FileConfig,
HostBlock,
ResolvedConfig,
RootConfig,
} from './config.ts'
export {
telemetryHeaders,
setTelemetryHeaders,
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
HEADER_RUNTIME,
} from './telemetry.ts'
export type { TelemetryIdentity } from './telemetry.ts'

View File

@ -0,0 +1,64 @@
import { version } from './index.ts'
/** Optional identity a host plugin knows at Honcho-client construction time. */
export interface TelemetryIdentity {
/** Host app name, e.g. `cursor`, `opencode`. */
host?: string
/** Host app version, e.g. `2026.8.1`. */
hostVersion?: string
/** Honcho plugin version, e.g. `0.1.2`. */
pluginVersion?: string
/** Agent completion model, e.g. `claude-sonnet-4-5`. Not a Honcho deriver/dialectic model. */
model?: string
}
export const HEADER_HOST = 'X-Honcho-Host'
export const HEADER_PLUGIN = 'X-Honcho-Plugin'
export const HEADER_RUNTIME = 'X-Honcho-Runtime'
export const HEADER_AGENT_MODEL = 'X-Honcho-Agent-Model'
function sanitize(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const s = value.replace(/[\r\n]+/g, ' ').trim()
return s || undefined
}
function hostValue(id: TelemetryIdentity): string | undefined {
const name = sanitize(id.host)
const ver = sanitize(id.hostVersion)
if (name && ver) return `${name}/${ver}`
return name || ver
}
/**
* Headers to pass as the SDK's `defaultHeaders`. Missing fields are omitted.
* `X-Honcho-Runtime` is always this package's version.
*/
export function telemetryHeaders(
id: TelemetryIdentity = {},
extra?: Record<string, string>
): Record<string, string> {
const headers: Record<string, string> = { [HEADER_RUNTIME]: version }
const host = hostValue(id)
const plugin = sanitize(id.pluginVersion)
const model = sanitize(id.model)
if (host) headers[HEADER_HOST] = host
if (plugin) headers[HEADER_PLUGIN] = plugin
if (model) headers[HEADER_AGENT_MODEL] = model
if (extra) {
for (const [k, v] of Object.entries(extra)) {
const value = sanitize(v)
if (value) headers[k] = value
}
}
return headers
}
/** Merge identity onto a live header map (e.g. `honcho.http.defaultHeaders`). */
export function setTelemetryHeaders(
headers: Record<string, string>,
id: TelemetryIdentity = {},
extra?: Record<string, string>
): Record<string, string> {
return Object.assign(headers, telemetryHeaders(id, extra))
}

View File

@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test'
import { normalizeBaseUrl, resolveConfig } from '../src/index.ts'
const emptyEnv = {}
describe('normalizeBaseUrl', () => {
test('adds https and lowercases the host', () => {
expect(normalizeBaseUrl('api.honcho.dev')).toBe('https://api.honcho.dev')
expect(normalizeBaseUrl('API.honcho.dev')).toBe('https://api.honcho.dev')
expect(normalizeBaseUrl('https://api.honcho.dev/')).toBe('https://api.honcho.dev')
})
test('leaves /v3 alone — the SDK owns the API version', () => {
expect(normalizeBaseUrl('https://api.honcho.dev/v3')).toBe('https://api.honcho.dev/v3')
})
test('localhost stays http', () => {
expect(normalizeBaseUrl('localhost:8000')).toBe('http://localhost:8000')
})
})
describe('resolveConfig', () => {
test('host block beats root; env beats host', () => {
const file = {
workspace: 'root-ws',
hosts: { a: { workspace: 'host-ws' } },
}
expect(resolveConfig(file, { host: 'a', env: emptyEnv }).workspace).toBe('host-ws')
expect(
resolveConfig(file, { host: 'a', env: { HONCHO_WORKSPACE: 'env-ws' } }).workspace
).toBe('env-ws')
})
test('root apiKey / workspaceId aliases still resolve', () => {
const cfg = resolveConfig(
{ apiKey: 'hch_x', workspaceId: 'from-id' },
{ host: 'a', env: emptyEnv }
)
expect(cfg.apiKey).toBe('hch_x')
expect(cfg.workspace).toBe('from-id')
})
test('v1 leftover environmentUrl is ignored', () => {
const cfg = resolveConfig(
{ schemaVersion: 1, baseUrl: 'https://keep.example', environmentUrl: 'https://old.example' },
{ host: 'a', env: emptyEnv }
)
expect(cfg.baseUrl).toBe('https://keep.example')
})
test('overlay sits below env', () => {
expect(
resolveConfig(
{},
{ host: 'a', overlay: { workspace: 'from-overlay' }, env: { HONCHO_WORKSPACE: 'from-env' } }
).workspace
).toBe('from-env')
expect(
resolveConfig({}, { host: 'a', overlay: { workspace: 'from-overlay' }, env: emptyEnv }).workspace
).toBe('from-overlay')
})
test('empty file uses built-ins; host name is not rewritten', () => {
const cfg = resolveConfig({}, { host: 'my-host', env: emptyEnv })
expect(cfg.baseUrl).toBe('https://api.honcho.dev')
expect(cfg.timeoutMs).toBe(30_000)
expect(cfg.enabled).toBe(true)
expect(cfg.host).toBe('my-host')
expect(cfg.workspace).toBe('my-host')
})
})

View File

@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import {
HEADER_AGENT_MODEL,
HEADER_HOST,
HEADER_PLUGIN,
HEADER_RUNTIME,
setTelemetryHeaders,
telemetryHeaders,
version,
} from '../src/index.ts'
describe('telemetryHeaders', () => {
test('empty identity still sends the runtime version', () => {
expect(telemetryHeaders()).toEqual({ [HEADER_RUNTIME]: version })
})
test('maps identity to headers', () => {
expect(
telemetryHeaders({
host: 'opencode',
hostVersion: '1.3.13',
pluginVersion: '0.1.3',
model: 'claude-sonnet-4-5',
})
).toEqual({
[HEADER_RUNTIME]: version,
[HEADER_HOST]: 'opencode/1.3.13',
[HEADER_PLUGIN]: '0.1.3',
[HEADER_AGENT_MODEL]: 'claude-sonnet-4-5',
})
})
test('merges extra headers last, skipping blanks', () => {
const headers = telemetryHeaders({ host: 'codex', pluginVersion: '0.1.1' }, {
'X-Custom': 'yes',
[HEADER_PLUGIN]: 'override',
'X-Empty': ' ',
})
expect(headers[HEADER_HOST]).toBe('codex')
expect(headers[HEADER_PLUGIN]).toBe('override')
expect(headers['X-Custom']).toBe('yes')
expect(headers).not.toHaveProperty('X-Empty')
})
})
describe('setTelemetryHeaders', () => {
test('mutates an existing header map in place', () => {
const headers = telemetryHeaders({ host: 'cursor', pluginVersion: '0.1.2' })
const returned = setTelemetryHeaders(headers, { model: 'claude-opus-4' })
expect(returned).toBe(headers)
expect(headers[HEADER_HOST]).toBe('cursor')
expect(headers[HEADER_PLUGIN]).toBe('0.1.2')
expect(headers[HEADER_RUNTIME]).toBe(version)
expect(headers[HEADER_AGENT_MODEL]).toBe('claude-opus-4')
})
})

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

43
schemas/config/v1.json Normal file
View File

@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://spec.honcho.dev/config/v1.json",
"type": "object",
"additionalProperties": true,
"$defs": {
"oauth": {
"type": "object",
"properties": {
"accessToken": { "type": "string" },
"refreshToken": { "type": "string" },
"expiresAt": { "type": "string" }
}
},
"auth": {
"type": "object",
"properties": {
"apiKey": { "type": "string" },
"oauth": { "$ref": "#/$defs/oauth" }
}
},
"hostBlock": {
"type": "object",
"additionalProperties": true,
"properties": {
"peerName": { "type": "string" },
"workspace": { "type": "string" },
"baseUrl": { "type": "string" },
"timeoutMs": { "type": "number" },
"enabled": { "type": "boolean" },
"auth": { "$ref": "#/$defs/auth" }
}
}
},
"allOf": [{ "$ref": "#/$defs/hostBlock" }],
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"hosts": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/hostBlock" }
}
}
}