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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.github
node_modules
npm-debug.log
work
outputs
38 changes: 38 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,41 @@ jobs:
- run: npm test
- run: npm audit --omit=dev
- run: npm pack --dry-run

python-sdk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
cache: pip
- run: npm ci
- run: python -m pip install -r fixtures/requirements.txt
- run: node --test test/openai-clients.test.mjs
env:
OPENAI_PYTHON_BIN: python

release-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Secret scan
run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:v8.28.0 detect --source /repo --no-git --redact --exit-code 1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Syntax checks
run: find src test fixtures -name '*.mjs' -print0 | xargs -0 -n1 node --check
- run: npm audit --omit=dev
- run: npm pack --dry-run
- run: git diff --check origin/${{ github.base_ref || 'main' }}...HEAD
- run: docker build -t hyperagent-codex-bridge:ci .
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- Stop emitting authoritative-looking zero token usage when Hyperagent MCP does not report token counts.
- Add request IDs, bounded durable local `Idempotency-Key` replay/conflict handling, and conservative cross-process duplicate prevention.
- Propagate client disconnect cancellation through MCP polling and record that remote thread cancellation is unavailable.
- Replace eager budget consumption with durable local reservation, commit-at-dispatch, and pre-dispatch release states.
- Add secret-free structured gateway logs plus separate liveness and readiness probes.
- Add loopback-only container/self-host guidance and mocked official OpenAI JavaScript/Python client fixtures.
- Fail closed on unknown, duplicate, or ambiguous model identifiers; default omitted `stream` to SSE; and bound every create and polling request with an abortable timeout.

## 0.4.2 — 2026-07-22

Release-hardening update focused on enforcing the cost controls claimed by v0.4.1.
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ Run the real Codex integration tests when Codex is installed:
CODEX_BIN="$(command -v codex)" npm test
```

The official OpenAI JavaScript client fixture runs in the default suite against a fully mocked bridge. To include the Python client fixture without making Python a Node package prerequisite:

```bash
python3 -m venv .venv
.venv/bin/pip install -r fixtures/requirements.txt
OPENAI_PYTHON_BIN="$PWD/.venv/bin/python" npm test
```

Both fixtures use local mock Hyperagent clients. They require no OAuth credentials, real provider calls, or credits.

## Pull request requirements

- Keep Node.js support at version 20 or newer.
Expand Down
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM node:22-alpine

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node src ./src
RUN mkdir /state && chown node:node /state

USER node
ENV HACB_HOME=/state
VOLUME ["/state"]
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["node", "-e", "fetch('http://127.0.0.1:47831/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "src/cli.mjs", "serve"]
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Release 0.4.0 is validated by an automated suite plus real Codex 0.144.6:
- local bearer authentication, rollback, and sanitized routing audit receipts;
- secret scan and archive integrity checks.

The public proof target is `hacb audit`: each successful turn produces model and Hyperagent thread IDs without storing prompts, answers, or credentials.
The public proof target is `hacb audit`: each successful turn produces model and hashed agent/thread references without storing prompts, answers, credentials, or raw private identifiers.

## Public release

Expand Down Expand Up @@ -104,6 +104,7 @@ You can also pass an exact agent name instead of its ID if the name is unique.
hacb doctor
hacb status
curl http://127.0.0.1:47831/health
curl http://127.0.0.1:47831/ready
codex --profile hyperagent
```

Expand Down Expand Up @@ -132,7 +133,13 @@ hacb app-status
hacb audit 12
```

The audit log records timestamps, model IDs, Hyperagent thread IDs, and completion type, but never prompts, outputs, or tokens. It is the proof that a Codex App turn traversed the bridge.
The audit log records timestamps, public model identifiers, hashed agent/thread/reservation references, and completion type, but never prompts, outputs, tokens, or raw private identifiers. It is the proof that a Codex App turn traversed the bridge.

Every HTTP response includes a server-generated `X-Request-Id`. Responses requests also include `X-Usage-Source: unavailable`: Hyperagent's documented MCP thread tools do not currently return authoritative token counts, so the bridge omits the Responses `usage` object instead of fabricating zeros.

`X-Request-Id` identifies one local HTTP attempt and never trusts a client-supplied value. A durable idempotency replay receives a new header request ID, while the replayed response body retains the originating dispatch ID in `metadata.request_id`.

Callers may send an `Idempotency-Key` header. Identical completed requests replay without another Hyperagent thread, including after a bridge restart; changed request bodies conflict, and in-progress or indeterminate outcomes fail closed. Idempotency records are stored in the private machine-state directory, can contain replayable response output, and expire after 24 hours. They are never written to the sanitized audit log or repository.

Do not remove the `hyperagent_credits` provider block while desktop chats created under it still exist; Codex persists the provider id with each chat.

Expand Down Expand Up @@ -167,6 +174,10 @@ hacb logout

`hacb serve` runs in the foreground and is best for debugging. Background startup errors are written to `~/.hyperagent-codex-bridge/bridge.log`.

Structured request logs are written to `~/.hyperagent-codex-bridge/gateway.jsonl`. They contain request IDs, route/status/timing fields, and sanitized error codes—never authorization headers, prompts, model output, OAuth tokens, or local bearer tokens. `/health` is a process-liveness probe. `/ready` additionally requires a reachable cached/recent agent listing and remaining local request budget.

For Linux service and loopback-only container operation, see `docs/SELF_HOSTING.md`. The gateway refuses non-loopback binds; do not expose it through a public listener or reverse proxy.

## Rollback

```bash
Expand Down Expand Up @@ -204,6 +215,8 @@ hacb audit 12

A Codex tool loop consumes at least two Hyperagent requests. Raising `maxRequestsPerDay`, input limits, or reasoning effort is an explicit operator decision, not an automatic behavior.

Budget state is reserved durably before provider dispatch, committed immediately before a `create_thread` attempt, and released only when the request is known not to have been dispatched. A disconnect after dispatch remains committed because the documented Hyperagent MCP surface cannot prove that the remote attempt stopped.

These controls do not replace the Hyperagent agent-level budget. Configure each relay agent with low effort and a hard per-run USD cap before production use.

## Security
Expand All @@ -224,6 +237,7 @@ These controls do not replace the Hyperagent agent-level budget. Configure each
5. **Codex Desktop model picker bug.** Current Codex Desktop builds may label custom provider models as `Custom` or hide them. The CLI/profile still sends the configured model ID correctly. Use `hacb profile <model-id>` or `codex --profile hyperagent -m <model-id>` when the picker is unreliable.
6. **Tool-call quality depends on the selected agent.** Its prompt should emphasize exact JSON tool selection and local Codex tool use. The bridge falls back to plain final text if the agent does not follow the relay schema.
7. **This is an adapter, not a Hyperagent product feature.** A future official Hyperagent Responses gateway would be faster and should replace this bridge.
8. **No provider-reported usage or remote cancellation.** Token usage is omitted, and disconnect cancellation stops only local polling after dispatch. Hyperagent platform support is required for authoritative usage/cost data, remote thread cancellation, and reconciliation of ambiguous `create_thread` outcomes.

## Sources

Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Never commit or share:

- `~/.hyperagent-codex-bridge/state.json`;
- `~/.hyperagent-codex-bridge/config.json`;
- `~/.hyperagent-codex-bridge/usage.json`;
- `~/.hyperagent-codex-bridge/idempotency.json`;
- `~/.hyperagent-codex-bridge/audit.jsonl` and `gateway.jsonl`;
- `~/.codex/auth.json`;
- generated `hyperagent.config.toml` or App Mode backups.

Expand Down
2 changes: 1 addition & 1 deletion docs/HYPERAGENT_ARTICLE_SECTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ I then added App Mode for the desktop client. Codex App does not yet expose CLI
hacb app-off
```

The project also emits sanitized routing receipts. `hacb audit` shows the model slug, Hyperagent thread ID, and completion type without storing prompts, answers, or credentials. That turns the integration from a screenshot claim into something auditable.
The project also emits sanitized routing receipts. `hacb audit` shows the model slug, a hashed Hyperagent thread reference, and completion type without storing prompts, answers, credentials, or raw private identifiers. That turns the integration from a screenshot claim into something auditable.

The public release includes:

Expand Down
4 changes: 2 additions & 2 deletions docs/LIVE_MAC_PROOF_2026-07-20.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ Versions: HACB 0.4.0, Node.js v22.22.3, Codex CLI 0.144.6
- The bridge routed app requests to named Hyperagent agents.
- Hyperagent returned a Codex client-tool request (`function_call`).
- Codex executed the local tool and returned its result for a final Hyperagent answer.
- Hyperagent thread IDs provide an independent audit trail.
- Hashed Hyperagent thread references provide an independent audit trail without publishing raw thread identifiers.
- The user observed Hyperagent credits being consumed while Codex subscription quota was not.

## Privacy

The receipt is safe to publish. It contains timestamps, model slugs, event types, and Hyperagent thread IDs only. It does not contain prompts, outputs, OAuth tokens, refresh tokens, local bridge secrets, or Codex authentication files.
The receipt is safe to publish. It contains timestamps, model slugs, event types, and hashed Hyperagent references only. It does not contain raw agent/thread/reservation identifiers, prompts, outputs, OAuth tokens, refresh tokens, local bridge secrets, or Codex authentication files.
2 changes: 1 addition & 1 deletion docs/RELEASE_NOTES_0.4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The first public proof-of-work release.
- Reversible `hacb app-on` / `hacb app-off` workflow for Codex App.
- Global `$hyperagent-codex-bridge` skill and Codex plugin manifest.
- macOS and Windows/Ryzen setup guides.
- Sanitized `hacb audit` receipts with Hyperagent thread IDs.
- Sanitized `hacb audit` receipts with hashed Hyperagent thread references.
- 19 passing tests, including real Codex CLI 0.144.6 provider, local tool-loop, and main-config App Mode runs.

## Live validation
Expand Down
44 changes: 44 additions & 0 deletions docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Self-hosting and containers

The supported security boundary is a same-machine gateway bound to `127.0.0.1` (or `::1`) with local bearer authentication. The bridge refuses `0.0.0.0`, public interfaces, and container port-publication configurations that would widen that boundary.

## Native service (recommended)

Install and authorize as the unprivileged account that runs Codex:

```bash
npm install -g .
hacb setup
hacb doctor
```

For a service manager, run `hacb serve` as that same account, set `HACB_HOME` to a private persistent directory, restart on failure, and use these probes:

```text
GET http://127.0.0.1:47831/health # process liveness only
GET http://127.0.0.1:47831/ready # agent availability and budget capacity
```

Protect the state directory as user-only. It contains OAuth state, the local bearer token, budget state, and sanitized audit/structured logs. Do not copy it into images, source control, backups shared with other users, or CI artifacts.

## Linux container

Container NAT cannot reach a service that is deliberately bound to the container's own loopback interface. On a single-user Linux host, the provided `Dockerfile` can run with host networking so the container and Codex share the host loopback namespace:

```bash
docker build -t hacb-local .
docker volume create hacb-state
docker run --rm -it --network host -v hacb-state:/state hacb-local node src/cli.mjs login
docker run -d --name hacb --restart unless-stopped --network host -v hacb-state:/state hacb-local
```

Run `hacb profile` natively with the same selected model, or mount only the generated profile where Codex expects it. Never bake `/state`, a generated profile, or any token into the image.

Do not use `-p 47831:47831`, Kubernetes Service/Ingress objects, a public load balancer, or a reverse proxy. They either will not work with loopback binding or would require weakening the intended trust boundary. Docker Desktop networking on macOS/Windows does not provide the same supported loopback relationship; use the native service there.

## Operational limits

- `/ready` may contact Hyperagent's documented OAuth MCP endpoint to establish or refresh the recent agent cache. It does not create a thread or intentionally consume a sampling request.
- A client disconnect aborts local MCP polling. There is no documented Hyperagent remote-cancel operation, so an already-dispatched thread may continue and its local budget slot remains committed.
- The gateway cannot report token or dollar usage because the documented MCP thread result does not provide authoritative usage. Responses omit `usage` and identify the source as unavailable.
- Bounded local idempotency state is stored with machine-local permissions and a 24-hour default lifetime. It coordinates bridge processes and replays completed responses after restart. An indeterminate `create_thread` outcome remains blocked after restart because the provider does not offer authoritative upstream idempotency or outcome lookup by local key.
18 changes: 18 additions & 0 deletions fixtures/openai-js-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import OpenAI from 'openai';

const [baseURL, apiKey] = process.argv.slice(2);
if (!baseURL || !apiKey) throw new Error('Usage: node openai-js-client.mjs <base-url> <api-key>');

const client = new OpenAI({ baseURL, apiKey, maxRetries: 0 });
const response = await client.responses.create({
model: 'hyperagent/sol-coder',
input: 'Mocked JavaScript client fixture.',
stream: false
}, { headers: { 'Idempotency-Key': 'openai-js-fixture' } });

process.stdout.write(JSON.stringify({
id: response.id,
text: response.output_text,
requestId: response.metadata?.request_id,
usage: response.usage ?? null
}));
22 changes: 22 additions & 0 deletions fixtures/openai_python_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import json
import sys

from openai import OpenAI


if len(sys.argv) != 3:
raise SystemExit("Usage: openai_python_client.py <base-url> <api-key>")

client = OpenAI(base_url=sys.argv[1], api_key=sys.argv[2], max_retries=0)
response = client.responses.create(
model="hyperagent/sol-coder",
input="Mocked Python client fixture.",
stream=False,
extra_headers={"Idempotency-Key": "openai-python-fixture"},
)
print(json.dumps({
"id": response.id,
"text": response.output_text,
"request_id": (response.metadata or {}).get("request_id"),
"usage": response.usage.model_dump() if response.usage else None,
}))
1 change: 1 addition & 0 deletions fixtures/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
openai>=1,<3
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@
"node": ">=20"
},
"license": "MIT",
"private": true
"private": true,
"devDependencies": {
"openai": "^6.48.0"
}
}
6 changes: 3 additions & 3 deletions src/app-mode.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { access, chmod, mkdir, readFile, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { atomicWriteText, ensureStateDir, modelCatalogPath } from './config.mjs';
import { atomicWriteText, bridgeUrl, ensureStateDir, modelCatalogPath } from './config.mjs';
import { codexHome, generateCatalog } from './install.mjs';

const ACTIVE_BEGIN = '# BEGIN Hyperagent Codex Bridge app mode';
Expand Down Expand Up @@ -106,7 +106,7 @@ function removeRootSelections(text) {
}

function providerBlock(config) {
const baseUrl = `http://${config.bridgeHost}:${config.bridgePort}/v1`;
const baseUrl = bridgeUrl(config, '/v1');
return [
PROVIDER_BEGIN,
`[model_providers.${config.codexProviderId}]`,
Expand Down Expand Up @@ -135,7 +135,7 @@ function activeBlock(config, model) {
}

export async function activateAppMode(config, { model, agents = null } = {}) {
if (config.bridgeHost !== '127.0.0.1' && config.bridgeHost !== 'localhost') {
if (!['127.0.0.1', 'localhost', '::1'].includes(config.bridgeHost)) {
throw new Error('App Mode requires the bridge to bind to loopback.');
}
await ensureStateDir();
Expand Down
Loading
Loading