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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ npm install # install
docker compose up --build # dev
npm test # unit + bootstrap shell suites (no install needed)
npm run test:schema # validate openclaw.json against OpenClaw's own schema (needs the openclaw CLI)
npm run test:agent-turn # headless agent-turn gate against a fresh reconciled config (needs Docker; see docs/operations/agent-automation.md)
npm run check:ci-hub-plugin-drift # verify the vendored ci-hub plugin bundle against a CI-Hub checkout
docker compose build # build
```

Expand Down
1 change: 1 addition & 0 deletions ANTIGRAVITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ npm install
docker compose up --build
npm test # unit + bootstrap shell suites (no install needed)
npm run test:schema # validate openclaw.json against OpenClaw's own schema (needs the openclaw CLI)
npm run test:agent-turn # headless agent-turn gate against a fresh reconciled config (needs Docker)
```

## Rules
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ npm test
# Needs the CLI: npm i -g "openclaw@$(scripts/openclaw-pin.sh)"
npm run test:schema

# Headless agent-turn gate against a fresh reconciled config (needs Docker)
npm run test:agent-turn

# Build for production
docker compose build
```
Expand Down
2 changes: 1 addition & 1 deletion CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ of this repository stands on them.
without it. See [NOTICE.md](NOTICE.md) for the licence terms that govern that
derivation.

## 3. Runtime dependencies
## 2. Runtime dependencies

| | Project | Licence | What it does |
| :-: | --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ licence (MIT, Apache-2.0, ISC, BSD or similar).

Except as noted above, the contents of this repository are
Copyright © LifeScope INC, DBA Companion Intelligence (<https://ci.computer>),
licensed under the terms in [LICENSE](LICENSE) / [LICENSE.md](LICENSE.md).
licensed under the terms in [LICENSE](LICENSE).

---

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,10 @@ The plugin calls:
- `GET /api/agent/intents` (falls back to `GET /api/intents`)
- `POST /api/intents/:name/execute` (tried as `POST /api/agent/intents/:name/execute` first)

`POST /api/memory/write` is the durable write endpoint for explicit notes. Older
Hermes docs may mention `/api/memory/note`; CI-OpenClaw uses `/write` first and
only falls back to `/note` for compatibility. `POST /api/memory/turn` is the
transcript-ingest endpoint used by automatic turn-sync.
`POST /api/memory/write` is the durable write endpoint for explicit notes. It
replaced the older Hermes `/api/memory/note` route on 2026-06-03; CI-OpenClaw
calls only `/write` and does not fall back to `/note`, which no longer exists.
`POST /api/memory/turn` is the transcript-ingest endpoint used by automatic turn-sync.

### Unit tests

Expand Down Expand Up @@ -323,7 +323,7 @@ Two plugin behaviours are **not** what they used to be:
- **`HUB_MCP_LEGACY_CLIENT=true` is no longer a working rollback.** It registered Hub tools in-plugin after awaiting `tools/list`, but OpenClaw requires `register()` to be synchronous and closes the plugin API once it returns — a late `registerTool()` is silently discarded. The flag now logs an error instead of pretending to work. Hub tools come from `mcp.servers.ci-hub`.
- **The plugin no longer registers an inference/model provider** (the `CI_HUB_PLUGIN_INFERENCE` opt-in is gone). A provider registered through the plugin API resolves its transport against OpenClaw's api-provider registry, which has no `ollama` implementation — so a plugin-registered `ci-hub` provider with `api: "ollama"` made the first LLM call fail with `No API provider registered for api: ollama`, taking chat down. (A provider declared in `openclaw.json` resolves `ollama` fine, via a separate provider-runtime path the plugin API never reaches.) `server.cjs` / `config-reconcile.cjs` already write the working `models.providers.ci-hub` into `openclaw.json` — the single source of truth for the `ci-hub` provider on an appliance. See companionintelligence/CI-Hub#895.

Hub → Settings → AI cloud tokens (`CI_CLOUD_OPENAI_*`, `CI_CLOUD_ANTHROPIC_*`, `CI_CLOUD_GOOGLE_*`, `CI_CLOUD_GITHUB_COPILOT_*`) are additive. `config-reconcile.cjs` writes `models.providers.{openai,anthropic,google,github-copilot}` so those providers appear in the picker **alongside** `ci-hub`. Local Ollama/vLLM stays the default when healthy. Removing a Hub key removes that provider on the next restart. Do not put a `hubManaged` flag on those entries — OpenClaw's schema rejects unknown keys and quarantines the whole file.
Hub → Settings → AI cloud tokens (`CI_CLOUD_OPENAI_*`, `CI_CLOUD_ANTHROPIC_*`, `CI_CLOUD_GOOGLE_*`, `CI_CLOUD_GITHUB_COPILOT_*`) are additive. `config-reconcile.cjs` writes `models.providers.{openai,anthropic,google,github-copilot}` so those providers appear in the picker **alongside** `ci-hub`. Local Ollama stays the default when healthy. (vLLM is referenced elsewhere as a future local backend, but `config-reconcile.cjs` only implements Ollama-native discovery/protocol today — pointing it at a vLLM server will not work yet.) Removing a Hub key removes that provider on the next restart. Do not put a `hubManaged` flag on those entries — OpenClaw's schema rejects unknown keys and quarantines the whole file.

If Hub MCP is disabled (`HUB_MCP_ENABLED=false` in the container env), bootstrap steps no-op gracefully (and remove any prior `mcp.servers.ci-hub` entry). Run `cihub mcp setup` on the Hub to provision a key for existing installs.

Expand Down Expand Up @@ -362,4 +362,4 @@ Verify against a checkout without changing anything: `npm run check:ci-hub-plugi

## License

MIT
[PolyForm Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) — see [LICENSE](LICENSE) and the [License FAQ](docs/License-FAQ.md) for what that means in practice.
96 changes: 76 additions & 20 deletions config-reconcile.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1192,18 +1192,49 @@ function createConfigReconciler({
*/
function syncCloudProviders(config, env, providers) {
let changed = false;
for (const spec of HUB_CLOUD_PROVIDER_SPECS) {
const apiKey = resolveEnvValue(env, spec.key);
const current = isPlainObject(providers.node[spec.id])
// Registering a cloud provider in models.providers doesn't make it selectable —
// it also needs an entry in agents.defaults.models, the same way the ci-hub and
// ollama catalogs do a few hundred lines up. Only touch a spec's own `${id}/*`
// keys when this sync actually owns that id this run (it's Hub-managed now, or
// it was a moment ago) — an unrelated hand-authored `anthropic/...` entry with no
// Hub key and no prior models.providers.anthropic is not ours to prune.
//
// Resolve every spec's apiKey/current up front so we can also tell, before
// touching anything, whether no cloud provider is or was ever active — a config
// with zero Hub cloud creds must not gain an empty agents.defaults node.
const specStates = HUB_CLOUD_PROVIDER_SPECS.map((spec) => ({
spec,
apiKey: resolveEnvValue(env, spec.key),
current: isPlainObject(providers.node[spec.id])
? providers.node[spec.id]
: null;
: null,
}));
const anyActive = specStates.some(({ apiKey, current }) => apiKey || current);
const defaults = anyActive
? ensureRecordPathTracked(config, ["agents", "defaults"])
: null;
if (defaults) {
changed = defaults.replaced || changed;
}
const existingModels = defaults && isPlainObject(defaults.node.models)
? defaults.node.models
: {};
const mergedModels = { ...existingModels };
let modelsChanged = false;
for (const { spec, apiKey, current } of specStates) {
if (!apiKey) {
if (current) {
delete providers.node[spec.id];
changed = true;
log.log(
`Patched config: removed ${spec.id} cloud provider (Hub key absent)`,
);
for (const key of Object.keys(mergedModels)) {
if (key.startsWith(`${spec.id}/`)) {
delete mergedModels[key];
modelsChanged = true;
}
}
}
continue;
}
Expand Down Expand Up @@ -1237,6 +1268,22 @@ function createConfigReconciler({
changed = true;
log.log(`Patched config: synced ${spec.id} cloud provider`);
}
const desiredKey = modelId ? `${spec.id}/${modelId}` : null;
for (const key of Object.keys(mergedModels)) {
if (key.startsWith(`${spec.id}/`) && key !== desiredKey) {
delete mergedModels[key];
modelsChanged = true;
}
}
if (desiredKey && !(desiredKey in mergedModels)) {
mergedModels[desiredKey] = existingModels[desiredKey] || {};
modelsChanged = true;
}
}
if (defaults && modelsChanged) {
defaults.node.models = mergedModels;
changed = true;
log.log("Patched config: merged cloud provider model options");
}
return changed;
}
Expand Down Expand Up @@ -1902,24 +1949,33 @@ function createConfigReconciler({
const existingModels = isPlainObject(defaults.node.models)
? defaults.node.models
: {};
const mergedModels = Object.fromEntries(
Object.entries(existingModels).filter(
([key]) =>
!key.startsWith("ci-hub/") && !key.startsWith("ollama/"),
),
);
for (const model of hubModels) {
const key = `ci-hub/${model.id}`;
mergedModels[key] = existingModels[key] || {};
// Mutate in place rather than filter-then-rebuild: a filtered-and-reinserted
// object always re-appends ci-hub/ollama keys after whatever other-prefixed
// keys (e.g. a cloud provider's) happened to precede them, which reorders the
// record and makes `changed` fire on an otherwise-idempotent reconcile every
// time a non-ci-hub/ollama key is also present.
const desiredModelKeys = new Set([
...hubModels.map((model) => `ci-hub/${model.id}`),
...ollamaModels.map((model) => `ollama/${model.id}`),
]);
let modelsChanged = false;
for (const key of Object.keys(existingModels)) {
if (
(key.startsWith("ci-hub/") || key.startsWith("ollama/")) &&
!desiredModelKeys.has(key)
) {
delete existingModels[key];
modelsChanged = true;
}
}
for (const model of ollamaModels) {
const key = `ollama/${model.id}`;
mergedModels[key] = existingModels[key] || {};
for (const key of desiredModelKeys) {
if (!(key in existingModels)) {
existingModels[key] = {};
modelsChanged = true;
}
}
if (
JSON.stringify(defaults.node.models || {}) !== JSON.stringify(mergedModels)
) {
defaults.node.models = mergedModels;
if (modelsChanged) {
defaults.node.models = existingModels;
changed = true;
log.log("Patched config: merged ci-hub + ollama model options");
}
Expand Down
8 changes: 5 additions & 3 deletions docs/operations/error-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ is never loaded at all, which is the strongest form of "no outbound traffic".
posture only becomes real for users once the DSN and the toggle ship
**together**. Shipping the DSN without the toggle would mean on-by-default with
no user-facing way to turn it off.
- **Release + source map upload in CI.** `docker-publish.yml` does not pass
`SENTRY_RELEASE` or upload source maps. Not needed while the wrapper ships
unbundled CommonJS.
- **Source map upload.** Not applicable — the wrapper ships unbundled
CommonJS, so there is nothing to source-map. `SENTRY_RELEASE` itself is
already passed: `docker-publish.yml` bakes
`SENTRY_RELEASE=ci-openclaw-server@<tag>` as a build-arg alongside
`SENTRY_DSN` on every release build.
19 changes: 18 additions & 1 deletion tests/config-reconcile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,12 @@ describe("reconcileConfig (end to end)", () => {
`${id} must not carry hubManaged (schema-invalid)`,
);
}
// Registering a provider in models.providers isn't enough to make it pickable —
// it also needs a matching entry in agents.defaults.models.
assert.ok("openai/gpt-4o" in config.agents.defaults.models);
assert.ok("anthropic/claude-opus-4" in config.agents.defaults.models);
assert.ok("google/gemini-2.5-pro" in config.agents.defaults.models);
assert.ok("github-copilot/gpt-4o" in config.agents.defaults.models);
assert.ok(patches().some((line) => line.includes("synced openai cloud provider")));

const first = JSON.stringify(config);
Expand Down Expand Up @@ -1808,20 +1814,31 @@ describe("reconcileConfig (end to end)", () => {
},
},
},
agents: {
defaults: {
models: { "openai/gpt-4o": {}, "anthropic/claude-opus-4": {} },
},
},
},
env: {
OLLAMA_HOST: baseUrl,
CI_CLOUD_ANTHROPIC_API_KEY: "sk-ant",
CI_CLOUD_ANTHROPIC_MODEL: "claude-opus-4",
},
});
const { cfg } = makeReconciler(dataDir);
await cfg.reconcileConfig();

const providers = readCfg(dataDir).models.providers;
const config = readCfg(dataDir);
const providers = config.models.providers;
assert.strictEqual(providers.openai, undefined);
assert.strictEqual(providers.anthropic.apiKey, "sk-ant");
assert.ok(!("hubManaged" in providers.anthropic));
assert.strictEqual(providers["ci-hub"].api, "ollama");
// A provider dropped from models.providers must also drop out of the
// selectable-models list; a still-configured one must stay pickable.
assert.ok(!Object.keys(config.agents.defaults.models).some((k) => k.startsWith("openai/")));
assert.ok("anthropic/claude-opus-4" in config.agents.defaults.models);
});
});
});
Expand Down
Loading