Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion hypaware-core/plugins-workspace/ai-gateway/src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,13 +369,21 @@ function errorDetail(err) {
* @param {string} upstreamHost
* @returns {OutgoingHttpHeaders}
*/
function forwardHeaders(reqHeaders, upstreamHost) {
export function forwardHeaders(reqHeaders, upstreamHost) {
/** @type {OutgoingHttpHeaders} */
const out = {}
for (const key of Object.keys(reqHeaders)) {
const lower = key.toLowerCase()
if (lower === 'host') continue
if (HOP_BY_HOP_HEADERS.has(lower)) continue
// `x-hypaware-*` request headers are gateway-local metadata that
// client adapters inject for projector matching (e.g. OpenClaw's
// `x-hypaware-client` and its `x-hypaware-marker` undo record).
// They are stripped here, AFTER the exchange recorder captured the
// original request headers, so projectors still see them but no
// provider ever does.
// @ref LLP 0109#consequences [implements]: inert metadata, never forwarded upstream
if (lower.startsWith('x-hypaware-')) continue
const value = reqHeaders[key]
if (value === undefined) continue
out[key] = value
Expand Down
40 changes: 40 additions & 0 deletions hypaware-core/plugins-workspace/openclaw/hypaware.plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"schema_version": 1,
"name": "@hypaware/openclaw",
"version": "1.0.0",
"description": "OpenClaw client adapter for HypAware. Injects a dedicated 'hypaware' model provider into OpenClaw's openclaw.json to route Anthropic traffic through the local AI gateway, and projects the captured Anthropic Messages exchanges into ai_gateway_messages.",
"hypaware_api": "^1.0.0",
"runtime": "node",
"node_engine": ">=20",
"entrypoint": "./src/index.js",
"permissions": [
"read_home",
"write_home",
"read_state",
"write_openclaw_settings"
],
"requires": {
"capabilities": {
"hypaware.ai-gateway": "^2.0.0"
}
},
"contributes": {
"client": {
"name": "openclaw",
"skill_dir": ".openclaw/skills",
"attach_probe": {
"format": "json_path",
"settings_file": ".openclaw/openclaw.json",
"marker_path": "models.providers.hypaware",
"marker_record": "headers.x-hypaware-marker"
},
"required_upstreams": ["anthropic"]
},
"config_sections": [
{
"section": "openclaw",
"summary": "OpenClaw adapter config, including the optional attach-on-join policy { on_join }."
}
]
}
}
81 changes: 81 additions & 0 deletions hypaware-core/plugins-workspace/openclaw/src/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @ts-check

/**
* Config validation for the `@hypaware/openclaw` plugin's own `config`
* block. v1 validates only the optional `attach` sub-object that drives
* attach-on-join, `{ on_join }`. Unlike the Claude/Codex sections there
* is no `backfill` block: the plugin registers no backfill provider in
* v1 (LLP 0109 lists OpenClaw session JSONL import as an open question),
* so accepting a backfill policy would be dead config surface. Every
* other key passes through untouched so existing configs keep working;
* nothing new for core to validate.
*
* Pure and dependency-free: it returns a `ValidationResult` so it plugs
* straight into `ctx.configRegistry.registerSection` and is callable from
* tests without spinning up observability.
*
* @import { ValidationError, ValidationResult } from '../../../../hypaware-plugin-kernel-types.js'
*/

/** Manifest `config_sections[].section` name this validator backs. */
export const OPENCLAW_CONFIG_SECTION = 'openclaw'

/**
* Validate the `@hypaware/openclaw` plugin config slice. Only the
* optional `attach` policy block is checked; unknown sibling keys are
* ignored so the validator stays additive over the existing config
* surface.
*
* @ref LLP 0037#per-plugin-config-kernel-generic-reconciler [implements]:
* the client adapter owns and validates its own config section; the
* kernel reconciler adds no top-level schema.
*
* @param {unknown} value
* @returns {ValidationResult}
*/
export function validateOpenclawConfig(value) {
if (value === undefined || value === null) return { ok: true }
if (typeof value !== 'object' || Array.isArray(value)) {
return { ok: false, errors: [{ pointer: '', message: 'openclaw config must be an object' }] }
}
const raw = /** @type {Record<string, unknown>} */ (value)
const errors = validateAttachSection(raw.attach, '/attach')
if (errors.length > 0) return { ok: false, errors }
return { ok: true }
}

/**
* Validate the optional `attach` policy block on a client-adapter plugin's
* config: `on_join` (whether the daemon auto-attaches this client when a
* joined host confirms a central config that enables it, boolean,
* default true). Optional; unknown keys are rejected so a typo
* (`on_joins`) surfaces instead of being silently ignored. Pure - the
* caller chooses where the returned pointers mount.
*
* @ref LLP 0045#part-4--per-plugin-attach-config--status-surface [implements]:
* attach.on_join rides the client adapter's own config block, validated
* by this plugin's config-section validator; no top-level/core schema.
*
* @param {unknown} value
* @param {string} pointer JSON-pointer prefix for the `attach` object
* @returns {ValidationError[]}
*/
export function validateAttachSection(value, pointer) {
/** @type {ValidationError[]} */
const errors = []
if (value === undefined) return errors
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
errors.push({ pointer, message: 'attach must be an object' })
return errors
}
const raw = /** @type {Record<string, unknown>} */ (value)
if (raw.on_join !== undefined && typeof raw.on_join !== 'boolean') {
errors.push({ pointer: `${pointer}/on_join`, message: 'attach.on_join must be a boolean' })
}
for (const key of Object.keys(raw)) {
if (key !== 'on_join') {
errors.push({ pointer: `${pointer}/${key}`, message: `unknown attach key '${key}'` })
}
}
return errors
}
125 changes: 125 additions & 0 deletions hypaware-core/plugins-workspace/openclaw/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// @ts-check

import os from 'node:os'

import { Attr, getLogger, withSpan } from '../../../../src/core/observability/index.js'
import { OPENCLAW_CONFIG_SECTION, validateOpenclawConfig } from './config.js'
import { attach, defaultSettingsPath } from './settings.js'
import { anthropicUpstreamPreset, createOpenclawExchangeProjector } from './projector.js'

/**
* @import { AiGatewayCapability, AiGatewayClientAttachContext, PluginActivationContext } from '../../../../hypaware-plugin-kernel-types.js'
*/

const PLUGIN_NAME = '@hypaware/openclaw'
const CLIENT_NAME = 'openclaw'
const UPSTREAM_NAME = 'anthropic'

/**
* The plugin's `config_sections` validator, surfaced as a side-effect-free
* export so the kernel apply path can validate this plugin's `config` block
* (the `attach` policy) *before* the plugin is ever activated (e.g. a
* central config that first introduces `@hypaware/openclaw`). It is the
* same registration `activate()` hands `ctx.configRegistry.registerSection`;
* importing this module never runs `activate()`, so discovery is safe.
*
* @ref LLP 0037#per-plugin-config-kernel-generic-reconciler [implements]: the plugin owns + exposes its own config validator
* @type {{ section: string, validate: typeof validateOpenclawConfig }}
*/
export const configSection = { section: OPENCLAW_CONFIG_SECTION, validate: validateOpenclawConfig }

/**
* Activate the `@hypaware/openclaw` adapter plugin.
*
* Resolves the `hypaware.ai-gateway@^2.0.0` capability, registers the
* Anthropic upstream preset and the header-gated OpenClaw exchange
* projector, and wires `attach()` against `~/.openclaw/openclaw.json`.
*
* `attach()` emits a `client.attach` span tagged with `hyp_plugin`,
* `client_name`, `status`, and `restored=true|false`. The reversing
* detach is the single core disk-driven undo (LLP 0045 Part 3, `json_path`
* format), not a per-adapter hook. No skills ship in v1
* (`skill_dir` is declared in the manifest for the follow-up).
*
* @param {PluginActivationContext} ctx
* @ref LLP 0016#knows-nothing-about-claude-or-codex [implements]: adapter requires the ai-gateway capability; registers client + upstream preset
*/
export async function activate(ctx) {
ctx.configRegistry.registerSection({
plugin: PLUGIN_NAME,
section: OPENCLAW_CONFIG_SECTION,
validate: validateOpenclawConfig,
})

/** @type {AiGatewayCapability} */
const gateway = ctx.requireCapability('hypaware.ai-gateway', '^2.0.0')

const upstreamPreset = anthropicUpstreamPreset()
if (upstreamPreset.name !== UPSTREAM_NAME) {
throw new Error(`@hypaware/openclaw: unexpected upstream preset name ${upstreamPreset.name}`)
}
// The Claude plugin may or may not be active, and the gateway API
// exposes no has/list for presets: registerUpstreamPreset() is a
// last-write-wins Map.set keyed on the preset name. Registering
// unconditionally is therefore exactly "register iff not already
// present": both plugins contribute the identical `anthropic` preset,
// so whichever registers last changes nothing.
// @ref LLP 0109#gateway-capture [implements]: registers the anthropic upstream preset itself iff not already present
gateway.registerUpstreamPreset(upstreamPreset)

gateway.registerExchangeProjector(createOpenclawExchangeProjector())

const logger = getLogger('plugin.openclaw')

gateway.registerClient({
name: CLIENT_NAME,
defaultUpstream: UPSTREAM_NAME,
/** @param {AiGatewayClientAttachContext} attachCtx */
async attach(attachCtx) {
const homeDir = ctx.env.HOME ?? os.homedir()
const settingsPath = defaultSettingsPath(ctx.env, homeDir)

return withSpan(
'client.attach',
{
[Attr.PLUGIN]: PLUGIN_NAME,
[Attr.OPERATION]: 'client.attach',
client_name: CLIENT_NAME,
hyp_client: CLIENT_NAME,
dry_run: attachCtx.dryRun === true,
},
async (span) => {
try {
const result = await attach({
endpoint: attachCtx.endpoint,
stdout: attachCtx.stdout,
stderr: attachCtx.stderr,
dryRun: attachCtx.dryRun,
json: attachCtx.json,
env: ctx.env,
homeDir,
version: ctx.plugin.version,
settingsPath,
})
span.setAttribute('status', 'ok')
span.setAttribute('restored', false)
if (!attachCtx.dryRun) {
logger.info('client.attach.write', {
hyp_plugin: PLUGIN_NAME,
hyp_client: CLIENT_NAME,
settings_path: result.settingsPath,
action: result.action,
changed: result.changed === true,
})
}
} catch (err) {
span.setAttribute('status', 'failed')
span.setAttribute('restored', false)
throw err
}
},
{ component: 'plugin.openclaw' }
)
},
})
}
Loading