Skip to content
Open
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
9 changes: 8 additions & 1 deletion capture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,19 @@ Measured on one machine, and the reason the table is here rather than in a folde
| `mimo-v2.5` | mimo | `run` | 50,618 chars | 16 | - |
| `mimo-v2.5-pro` | mimo | `run` | 50,618 chars | 16 | - |
| `kilo-auto/free` | kilo | `run` | 11,326 chars | 13 | - |
| `nemotron-3.5-lightning-free` | hermes | `-z` | 7,742 chars | 19 | - |

The five rows below the OpenCode block were missing until now. Three of them carry no prefix
count: `mimo-v2.5` and `mimo-v2.5-pro` were captured with no valid key, so the server answered
`invalid_key` and reported no usage -- the prompt is unaffected, since it travels in the request,
which is why `capture.mjs` files these only under `--allow-failed` and says so. `grok-4.5-high`
and `kilo-auto/free` completed, but neither response carried a usage block to read.
and `kilo-auto/free` completed, but neither response carried a usage block to read. Hermes
completed too, on the same anonymous tier, and its response carried no usage block either.

`nemotron-3.5-lightning-free` appears twice, which is the most direct comparison in this
table: one free model, two harnesses, 9,655 characters and 11 tools from OpenCode against
7,742 and 19 from Hermes. Same model, same anonymous endpoint, different instructions and a
different tool surface -- the harness is the variable, and this is the pair that isolates it.

The two MiMo rows are identical on purpose: captured from the same directory, `mimo-v2.5`,
`mimo-v2.5-pro` and `mimo-v2.5-pro-ultraspeed` send byte-identical prompts and tool sets, same
Expand Down
39 changes: 39 additions & 0 deletions capture/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,45 @@ const PROFILES = {
extract: extractOpenAiShaped,
},

/**
* Hermes is the only Python harness here, and the only one whose origin cannot be moved at all.
*
* `OPENAI_BASE_URL` is not a route into it -- verified against a listener, not assumed -- so the
* origin stays where Hermes put it and `--tls-intercept` reads the traffic in place. The prompt
* travels in the request, so it is read the ordinary way.
*
* `--provider opencode-free` is the tier served anonymously, which is what makes this runnable
* with no key and no credential in the capture. Its origin is `opencode.ai`, a website as much
* as a gateway, so it is named explicitly rather than defaulted into the allowlist. `-z` is the
* non-interactive flag; without it Hermes opens a session and waits.
*/
hermes: {
id: 'hermes',
adapter: 'hermes',
promptDir: 'HERMES',
defaultInteractive: false,
recordFlags: ['--tls-intercept', '--tls-hosts', '+opencode.ai'],
defaultModel: 'nemotron-3.5-lightning-free',
recordArgs: (model, prompt) => [
'--',
'--provider',
'opencode-free',
...(model ? ['-m', model] : []),
'-z',
prompt,
],
consoleArgs: (model, prompt) => [
'--provider',
'opencode-free',
...(model ? ['-m', model] : []),
'-z',
`"${prompt}"`,
],
forceAnthropicUpstream: false,

extract: extractOpenAiShaped,
},

cursor: {
id: 'cursor',
adapter: 'exec',
Expand Down
10 changes: 10 additions & 0 deletions packages/adapters/fixtures/harness/hermes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"adapter": "hermes",
"harness_versions": ">=0.21.0",
"verified_at": "2026-09-04",
"command": "hermes",
"env_vars": [],
"base_urls": {},
"optional_env_vars": [],
"note": "Empty because Hermes has no base-URL variable to move, which was established against a listener rather than assumed: with config model.base_url pointed at a dead port and OPENAI_BASE_URL at a live one, the run dies on the dead port and the listener records nothing -- --provider custom included. The variable is read in exactly one place in the shipped source, an advisory warning that fires when it is set and the provider is not 'custom'. config.yaml's model.base_url is the only origin lever, and orca does not touch a user's global config. Interception is the route, and Hermes honours HTTPS_PROXY all the way down: through a proxy logging CONNECT targets, one question sends two of them to the model origin, the model-catalogue read and the completion. The trust store needs nothing added, which is worth recording because it looks as though it would. Hermes makes those two calls with two different HTTP libraries -- requests for the catalogue and httpx under the OpenAI SDK for the completion -- and httpx pins certifi. But Hermes resolves that client's CA itself, and the chain in agent/ssl_verify.py reads HERMES_CA_BUNDLE, then SSL_CERT_FILE, then REQUESTS_CA_BUNDLE, then CURL_CA_BUNDLE; orca already sets the second. Confirmed by removing a vendor-specific variable that had been added for this and recording again: same single model exchange, status 200, 10,615 input tokens. Verified against Hermes Agent v0.21.0 (2026.8.31), installed from git, by running it: `orca record hermes --tls-intercept --tls-hosts '+opencode.ai' -- --provider opencode-free -m nemotron-3.5-lightning-free -z 'Compute 212000 plus 8 and reply with only the number.'` recorded one model.request/model.response pair -- 42,305 bytes, 19 tools, a 7,742-character system prompt beginning 'You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask', answered streamed with status 200 for 10,615 input and 88 output tokens -- and `orca replay` reused 3/3. -z is the non-interactive flag; without it Hermes opens a session and waits. inference-api.nousresearch.com is in DEFAULT_TLS_HOSTS so Hermes on Nous' own models needs no extra --tls-hosts; portal.nousresearch.com is deliberately not, being where the API key is issued. The opencode-free tier used above is served anonymously, so no credential was in the recording; its origin needs an explicit +opencode.ai because opencode.ai is a website as much as a gateway."
}
64 changes: 64 additions & 0 deletions packages/adapters/src/hermes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Adapter, Launch, RecordContext } from '@orcareplay/plugin-api';
import { detectAgent } from './detect.js';

/**
* Hermes, Nous Research's agent — the first Python harness orca records.
*
* Every other adapter here fronts a Node harness, and that difference is the whole reason this
* one exists. Nothing is redirected: `OPENAI_BASE_URL` is not a route into Hermes. Its origin
* lives in `config.yaml` under `model.base_url`, and the environment variable is read in only one
* place -- an advisory warning that fires when it is set and the provider is *not* `custom`.
* Verified against a logging listener rather than inferred: with `model.base_url` pointed at a
* dead port and `OPENAI_BASE_URL` at a live one, the run dies on the dead port and the listener
* records nothing, `--provider custom` included.
*
* What does work is the transport. Hermes honours `HTTPS_PROXY` all the way down -- through a
* proxy that logs CONNECT targets, a single question sends two of them to the model origin, the
* model-catalogue read and the completion -- so `--tls-intercept` reaches it.
*
* The trust store needs no special handling, which is worth saying because it looks as though it
* would. Hermes makes those two calls with two different HTTP libraries -- `requests` for the
* catalogue and httpx under the OpenAI SDK for the completion -- and httpx pins certifi, so the
* usual `SSL_CERT_FILE` should not reach it. Hermes resolves that client's CA itself, and the
* chain in `agent/ssl_verify.py` reads `HERMES_CA_BUNDLE`, then `SSL_CERT_FILE`, then
* `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`. orca already sets the second, so the run CA is
* trusted by both clients with nothing added. Checked by removing the vendor variable and
* recording again: same one model exchange, status 200, 10,615 input tokens.
*
* `inference-api.nousresearch.com` is in `DEFAULT_TLS_HOSTS`, so Hermes on Nous' own models needs
* no extra `--tls-hosts`. Deliberately not in that list: `portal.nousresearch.com`, which is
* billing and subscription management and where the API key is issued.
*
* The fixture below was verified against `--provider opencode-free`, whose tier is served
* anonymously -- no key, so no credential to leak into a recording. That origin needs
* `--tls-hosts '+opencode.ai'` and does not get a default entry: unlike `api.kilo.ai` or
* `api.xiaomimimo.com` it is a website as much as a gateway, and a default that decrypted it would
* decrypt whatever else is served there.
*
* orca record hermes --tls-intercept --tls-hosts '+opencode.ai' -- \
* --provider opencode-free -m nemotron-3.5-lightning-free -z 'your question'
*
* `-z` is the non-interactive flag; without it Hermes opens a session and waits.
*/
export const hermesAdapter: Adapter = {
id: 'hermes',
aliases: ['hermes-agent', 'nous-hermes'],
harnessVersions: '>=0.21.0',

// Redirecting nothing is the intent, not a defect. See the `capture` field on `Adapter`.
capture: 'transport',

async detect(_cwd: string): Promise<boolean> {
// `AppData/Local/hermes` is where the Windows install keeps `config.yaml`; the XDG paths are
// the same directory on the other platforms. Home-relative, so an install moved with
// `HERMES_HOME` is still found by its binary.
return detectAgent(['hermes'], ['AppData/Local/hermes', '.config/hermes', '.hermes']);
},

async prepare(ctx: RecordContext): Promise<Launch> {
// No origin and no credential. Hermes keeps both in `config.yaml` and its own secret store,
// and an adapter that injected either would override a choice made there -- for the origin it
// could not anyway, per the note above.
return { command: 'hermes', args: [...ctx.userArgs], env: {} };
},
};
1 change: 1 addition & 0 deletions packages/adapters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './env.js';
export * from './exec.js';
export * from './generic-openai.js';
export * from './grok.js';
export * from './hermes.js';
export * from './node.js';
export * from './openclaw.js';
export * from './mcp-config.js';
Expand Down
2 changes: 2 additions & 0 deletions packages/adapters/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Adapter } from '@orcareplay/plugin-api';
import { claudeCodeAdapter } from './claude-code.js';
import { codexAdapter } from './codex.js';
import { cursorAdapter } from './cursor.js';
import { hermesAdapter } from './hermes.js';
import { kiloAdapter } from './kilo.js';
import { mimoAdapter } from './mimo.js';
import { execAdapter } from './exec.js';
Expand Down Expand Up @@ -94,6 +95,7 @@ export function defaultAdapters(): AdapterRegistry {
registry.register(mimoAdapter);
registry.register(kiloAdapter);
registry.register(cursorAdapter);
registry.register(hermesAdapter);
registry.register(grokAdapter);
registry.register(openClawAdapter);
registry.register(nodeAdapter);
Expand Down
1 change: 1 addition & 0 deletions packages/adapters/test/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ describe('AdapterRegistry', () => {
'mimo',
'kilo',
'cursor',
'hermes',
'grok',
'openclaw',
'node',
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/tls-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export async function setupTlsCapture(req: TlsCaptureRequest): Promise<TlsCaptur
*/
function warnUnclaimed(out: Output, reported: Set<string>, exchange: NetExchange): void {
if (exchange.method !== 'POST') return;
// No response ever arrived, so this one is not evidence that a path went unrecognised. It is
// kept as network traffic because an exchange with nothing to replay cannot be a model exchange
// whatever dialect exists -- and telling the operator to write one would not change that. A
// harness that opens a call and leaves would otherwise print this on every turn.
if (exchange.status === 0) return;
const body = exchange.requestBody.trim();
if (!body.startsWith('{')) return;
// A truncated body cannot parse — it was cut mid-JSON at the capture limit. Requiring a parse
Expand Down Expand Up @@ -308,6 +313,9 @@ export async function persistNetExchange(
headers: exchange.responseHeaders,
bytes: exchange.responseBytes,
truncated: exchange.responseTruncated,
// Only when it happened, so a normal exchange carries no field saying it was normal. It
// reads differently from `truncated`: the agent stopped reading, orca did not stop keeping.
...(exchange.abandoned ? { abandoned: true } : {}),
duration_ms: exchange.durationMs,
},
payload: exchange.responseBody as never,
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/fixtures/proxy-call.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,23 @@ export async function callThroughProxy(target, trust = trustFromEnv()) {
});
},
);
// Errors are the expected consequence of `leave`, not a failure of the call.
req.on('error', (err) => {
if (target.leave) return;
secure.destroy();
reject(err);
});
if (target.body !== undefined) req.write(target.body);
req.end();
if (target.leave) {
// The agent got what it came for and stopped reading. One beat first, so the request is on
// the wire before the socket goes -- that is what leaves a decrypted request with no
// response behind it.
setTimeout(() => {
secure.destroy();
resolve({ status: undefined, body: '', issuer, abandoned: true });
}, 150);
}
});
}

Expand Down
34 changes: 29 additions & 5 deletions packages/cli/test/tls-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,18 @@ describe('orca record --tls-intercept', () => {
let ambient: Record<string, string | undefined>;

async function startOrigin(
handler: (path: string) => { status: number; body: string },
handler: (path: string) => { status: number; body: string } | undefined,
): Promise<{ port: number; close: () => Promise<void> }> {
const issued = originCa.issue('127.0.0.1');
const server = createHttpsServer({ key: issued.keyPem, cert: issued.certPem }, (req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => void chunks.push(c));
req.on('end', () => {
const reply = handler(req.url ?? '/');
// `undefined` means never answer. That is the only way to hold an exchange in the state a
// client that leaves early puts it in -- request decrypted, no response header ever sent
// -- deterministically, rather than by racing a fast origin.
if (!reply) return;
res.writeHead(reply.status, { 'content-type': 'application/json' });
res.end(reply.body);
});
Expand All @@ -76,10 +80,11 @@ describe('orca record --tls-intercept', () => {
status: 200,
body: JSON.stringify({ served: path, note: 'MODEL-BODY-MARKER' }),
}));
bank = await startOrigin(() => ({
status: 200,
body: JSON.stringify({ balance: 'BANK-BODY-MARKER' }),
}));
bank = await startOrigin((path) =>
path === '/v3/never'
? undefined
: { status: 200, body: JSON.stringify({ balance: 'BANK-BODY-MARKER' }) },
);

lines = [];
out = new Output({ write: (s) => void lines.push(s), isTTY: false });
Expand Down Expand Up @@ -269,6 +274,25 @@ describe('orca record --tls-intercept', () => {
expect(warnings).toHaveLength(1);
});

it('stays quiet about a call the agent abandoned before the origin answered', async () => {
// Same path and the same JSON body as the warning test above, so the only difference is that
// this one never got a response. `add a dialect for it` cannot help an exchange with nothing
// to replay, and a harness that opens calls and leaves would print this every turn.
process.env.ORCA_TEST_TARGETS = JSON.stringify([
{
host: '127.0.0.1',
port: bank.port,
path: '/v3/never',
method: 'POST',
body: JSON.stringify({ prompt: 'hello' }),
leave: true,
},
]);
await record(['--tls-intercept', '--tls-hosts', `127.0.0.1:${bank.port}`]);

expect(lines.join('')).not.toContain('tls.unclaimed_path');
});

it('stays quiet about traffic that was never a model call to begin with', async () => {
// A GET, and a body that is not JSON. Warning here would train the operator to ignore the
// warning that matters, which is worse than not printing one.
Expand Down
Loading
Loading