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
35 changes: 29 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,45 @@ jobs:

security:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run security audit
run: npm audit --audit-level=moderate

- name: Check for vulnerabilities
run: npm audit --audit-level=high --production
run: npm audit --audit-level=high --production

validate-acp-providers:
# Fail the build if src/models/acp-providers.json drifts from the
# Python source of truth in openhands-sdk. Not gated by
# continue-on-error — drift is a bug and must block merging.
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install openhands-sdk
run: pip install -r scripts/requirements-acp-check.txt

- name: Check ACP_PROVIDERS matches openhands-sdk
env:
OPENHANDS_SUPPRESS_BANNER: '1'
run: python scripts/check-acp-drift.py
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ npm run test:integration
- Keep the root SDK surface ergonomic; lower-level endpoint clients belong in
`@openhands/typescript-client/clients`.

## ACP provider registry

The data in `src/models/acp-providers.json` mirrors
`openhands.sdk.settings.acp_providers.ACP_PROVIDERS` in
[software-agent-sdk](https://github.com/OpenHands/software-agent-sdk). The
Python module is the canonical source — edit `acp-providers.json` here when
it changes upstream. The `validate-acp-providers` CI job diffs the two on
every PR; to run it locally:

```bash
pip install -r scripts/requirements-acp-check.txt
python scripts/check-acp-drift.py
```

## Pull requests

- Open focused PRs with a clear description of what changed and why.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
}
},
"scripts": {
"build": "tsc && node ./scripts/rewrite-relative-imports.mjs",
"build": "tsc && node ./scripts/copy-json-assets.mjs && node ./scripts/rewrite-relative-imports.mjs",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\" \"scripts/**/*.mjs\"",
Expand Down
81 changes: 81 additions & 0 deletions scripts/check-acp-drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""ACP-registry drift check vs openhands-sdk.

Reads `src/models/acp-providers.json` (the TS-side source of truth) and
compares it field-for-field against `ACP_PROVIDERS` in the installed
openhands-sdk. Exits non-zero with a unified diff on mismatch.

Run locally:
pip install -r scripts/requirements-acp-check.txt
python scripts/check-acp-drift.py

CI runs this as the `validate-acp-providers` job on every PR + push to main.
"""

from __future__ import annotations

import dataclasses
import difflib
import json
import pathlib
import sys


def _normalize(value):
"""Coerce tuples to lists and dataclasses to dicts; recurse."""
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return _normalize(dataclasses.asdict(value))
if isinstance(value, dict):
return {k: _normalize(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize(v) for v in value]
return value


def _dump_json(obj) -> str:
return json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False) + "\n"


def main() -> int:
try:
from openhands.sdk.settings.acp_providers import ACP_PROVIDERS # type: ignore[import-not-found]
except ImportError as exc:
print(
"ERROR: cannot import openhands-sdk. "
"Run `pip install -r scripts/requirements-acp-check.txt` first.",
file=sys.stderr,
)
print(f" ({exc})", file=sys.stderr)
return 2

repo_root = pathlib.Path(__file__).resolve().parent.parent
ts_json_path = repo_root / "src" / "models" / "acp-providers.json"
ts_data = _normalize(json.loads(ts_json_path.read_text()))
py_data = _normalize(dict(ACP_PROVIDERS))

ts_text = _dump_json(ts_data)
py_text = _dump_json(py_data)

if ts_text == py_text:
n = len(ts_data) if isinstance(ts_data, dict) else 0
print(f"OK: src/models/acp-providers.json matches openhands-sdk ({n} providers).")
return 0

print(
"ERROR: src/models/acp-providers.json has drifted from openhands-sdk.\n"
"Update src/models/acp-providers.json to match the Python source at\n"
"openhands-sdk/openhands/sdk/settings/acp_providers.py.\n",
file=sys.stderr,
)
diff = difflib.unified_diff(
ts_text.splitlines(keepends=True),
py_text.splitlines(keepends=True),
fromfile="src/models/acp-providers.json",
tofile="openhands-sdk ACP_PROVIDERS",
)
sys.stderr.writelines(diff)
return 1


if __name__ == "__main__":
sys.exit(main())
39 changes: 39 additions & 0 deletions scripts/copy-json-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copy non-TS assets (.json) from src/ to dist/ after tsc runs.
// tsc does not emit imported JSON files even with resolveJsonModule;
// without this step, `import x from './acp-providers.json'` resolves at
// compile time but fails at runtime in the published package.

import { copyFile, mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SRC = path.join(ROOT, 'src');
const DIST = path.join(ROOT, 'dist');
const EXTENSIONS = new Set(['.json']);

async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walk(full)));
} else if (EXTENSIONS.has(path.extname(entry.name))) {
files.push(full);
}
}
return files;
}

const sources = await walk(SRC);
for (const src of sources) {
const rel = path.relative(SRC, src);
const dest = path.join(DIST, rel);
await mkdir(path.dirname(dest), { recursive: true });
await copyFile(src, dest);
}

if (sources.length > 0) {
console.log(`copy-json-assets: copied ${sources.length} file(s) to dist/`);
}
5 changes: 5 additions & 0 deletions scripts/requirements-acp-check.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Pinned dependency for scripts/check-acp-drift.py.
# Tracks `main` of OpenHands/software-agent-sdk intentionally: the drift
# check exists to fail CI when ACP_PROVIDERS moves upstream so this repo's
# JSON mirror gets updated. Do NOT pin to a tag or sha.
openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk@main#subdirectory=openhands-sdk
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ export type {
ConversationType,
} from './conversation/base';

// ACP provider registry (mirrors openhands-sdk; see scripts/validate-acp-providers.mjs)
export { ACP_PROVIDERS, ACP_SETTINGS_KEYS, getAcpProvider } from './models/acp';
export type { ACPProviderInfo, ACPProviderKey } from './models/acp';

// Conversation models
export type {
ConversationInfo,
Expand Down
35 changes: 35 additions & 0 deletions src/models/acp-providers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"claude-code": {
"key": "claude-code",
"display_name": "Claude Code",
"default_command": ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
"api_key_env_var": "ANTHROPIC_API_KEY",
"base_url_env_var": "ANTHROPIC_BASE_URL",
"default_session_mode": "bypassPermissions",
"agent_name_patterns": ["claude-agent"],
"supports_set_session_model": false,
"session_meta_key": "claudeCode"
},
"codex": {
"key": "codex",
"display_name": "Codex",
"default_command": ["npx", "-y", "@zed-industries/codex-acp"],
"api_key_env_var": "OPENAI_API_KEY",
"base_url_env_var": "OPENAI_BASE_URL",
"default_session_mode": "full-access",
"agent_name_patterns": ["codex-acp"],
"supports_set_session_model": true,
"session_meta_key": null
},
"gemini-cli": {
"key": "gemini-cli",
"display_name": "Gemini CLI",
"default_command": ["npx", "-y", "@google/gemini-cli", "--acp"],
"api_key_env_var": "GEMINI_API_KEY",
"base_url_env_var": "GEMINI_BASE_URL",
"default_session_mode": "yolo",
"agent_name_patterns": ["gemini-cli"],
"supports_set_session_model": true,
"session_meta_key": null
}
}
75 changes: 75 additions & 0 deletions src/models/acp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* ACP (Agent Client Protocol) provider registry — TypeScript mirror of the
* Python source of truth at
* `openhands.sdk.settings.acp_providers.ACP_PROVIDERS` in
* https://github.com/OpenHands/software-agent-sdk.
*
* The data lives in `./acp-providers.json` so the Python drift check in
* `scripts/check-acp-drift.py` can read it without executing TypeScript.
* To add or modify a provider, edit `acp_providers.py` in software-agent-sdk
* first, then mirror the change in `acp-providers.json` here. CI will fail
* until the two match.
*/

import providersData from './acp-providers.json';

/**
* Stable registry key for a built-in ACP provider.
*
* Does **not** include `'custom'` — `custom` is a UI-side discriminator
* meaning "user typed their own command" and intentionally has no registry
* entry.
*/
export type ACPProviderKey = 'claude-code' | 'codex' | 'gemini-cli';

/**
* Immutable metadata record for one built-in ACP provider. Mirrors
* `openhands.sdk.settings.acp_providers.ACPProviderInfo` field-for-field.
*/
export interface ACPProviderInfo {
readonly key: ACPProviderKey;
readonly display_name: string;
readonly default_command: readonly string[];
/** `null` for providers that authenticate via browser login. */
readonly api_key_env_var: string | null;
/** `null` if the provider does not support env-based base-URL override. */
readonly base_url_env_var: string | null;
/** ACP session-mode ID that suppresses all permission prompts. */
readonly default_session_mode: string;
/** Lowercase substring fragments matched against the runtime agent name. */
readonly agent_name_patterns: readonly string[];
/** `true` if this provider uses the `set_session_model` protocol call. */
readonly supports_set_session_model: boolean;
/** Top-level `_meta` key for model selection, or `null`. */
readonly session_meta_key: string | null;
}

export const ACP_PROVIDERS: Readonly<Record<ACPProviderKey, ACPProviderInfo>> =
providersData as Readonly<Record<ACPProviderKey, ACPProviderInfo>>;

/**
* Return the {@link ACPProviderInfo} for `key`, or `null` if unknown.
* Mirrors the Python `get_acp_provider` semantics — returns `null` for the
* UI-side `'custom'` discriminator and for any unknown / falsy value.
*/
export function getAcpProvider(key: string | null | undefined): ACPProviderInfo | null {
if (!key) {
return null;
}
return (ACP_PROVIDERS as Record<string, ACPProviderInfo | undefined>)[key] ?? null;
}

/**
* Allow-list of fields that travel on an `ACPAgent` settings payload.
* Mirrors `openhands.sdk.settings.model.ACPAgentSettings`'s field set.
* Clients filter ACP-only fields out when switching to a non-ACP variant.
*/
export const ACP_SETTINGS_KEYS: readonly string[] = [
'acp_command',
'acp_args',
'acp_env',
'acp_model',
'acp_session_mode',
'acp_prompt_timeout',
'acp_server',
];
Loading