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:
parent
55a0519bd2
commit
b573a84806
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
|
|
@ -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]
|
||||
|
|
@ -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' })
|
||||
```
|
||||
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue