Skip to content
Closed
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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ Rule (unchanged): any new social-presence primitive (typing-indicator, read-rece

- **Global Integrations UI changes require `reprovision-all`** to take effect — UI writes to DB, provisioner reads DB on each reprovision and writes to `/state/moltbot.json`.

- **Dev agents** (theo/nova/pixel/ops/aria) use `openai-codex/gpt-5.4-mini` for heartbeats via an explicit per-agent override. **Community agents** use `openrouter/nvidia/nemotron-3-super-120b-a12b:free` as primary — no Codex credentials are issued to them, so `openai-codex/*` is gated to dev agents only. **A hard assertion in `applyOpenClawModelDefaults` throws if any `openai-codex/*` model leaks into the community fallback chain** (PR #282) — so a future edit can't silently put community agents on Codex. Trinity removed 2026-05-03 (deregistered at OpenRouter). Gemini placeholders remain in the chain but are inert (`GEMINI_API_KEY` is for project 946211286881 where the API isn't enabled). LiteLLM router does ONE retry on 429 with a 1s delay (`num_retries: 1`, `retry_after: 1`) so the codex-auth-rotator has time to swap auth.json before the retry.
- **Dev agents** (theo/nova/pixel/ops/aria) use `openai-codex/gpt-5.4-mini` for heartbeats via an explicit per-agent override. **Community agents** use `openrouter/deepseek-v4-flash` as explicit primary through LiteLLM (the `openrouter` prefix is the OpenClaw transport label). Nemotron is excluded from the community chain until a probe shows it serving — no Codex credentials are issued to them, so `openai-codex/*` is gated to dev agents only. **A hard assertion in `applyOpenClawModelDefaults` throws if any `openai-codex/*` model leaks into the community fallback chain** (PR #282) — so a future edit can't silently put community agents on Codex. Trinity removed 2026-05-03 (deregistered at OpenRouter). Gemini placeholders remain in the chain but are inert (`GEMINI_API_KEY` is for project 946211286881 where the API isn't enabled). LiteLLM router does ONE retry on 429 with a 1s delay (`num_retries: 1`, `retry_after: 1`) so the codex-auth-rotator has time to swap auth.json before the retry.

- **`registry.js` is the permanent source of truth** for heartbeat templates. PVC HEARTBEAT.md edits are overwritten on `reprovision-all`.

Expand Down
162 changes: 154 additions & 8 deletions backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const mock = {
// these, so this is the only place a test can assert on the delivered file
// rather than on the source that composes it.
readNamespacedPodLog: jest.fn(),
execHandler: null,
execCommands: [],
execArgv: [],
// What the next exec writes to its stdout sink; empty = no output.
Expand Down Expand Up @@ -76,6 +77,8 @@ const mock = {
) {
mock.execCommands.push(Array.isArray(_command) ? _command.join(' ') : String(_command));
mock.execArgv.push(_command);
const handled = mock.execHandler?.(_command, _stdout, statusCallback);
if (handled) return handled;
if (mock.execStdout && _stdout && typeof _stdout.write === 'function') {
_stdout.write(mock.execStdout);
if (typeof statusCallback === 'function') setImmediate(() => statusCallback({ status: 'Success' }));
Expand All @@ -99,7 +102,7 @@ const mock = {
const k8s = require('@kubernetes/client-node');
const {
restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey,
readOpenClawIdentityFile, getAgentRuntimeLogs,
readOpenClawIdentityFile, getAgentRuntimeLogs, issueLiteLLMOpenRouterKey,
} = require('../../../services/agentProvisionerServiceK8s');
const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets');

Expand Down Expand Up @@ -388,14 +391,16 @@ describe('agentProvisionerServiceK8s', () => {
expect(config.agents.defaults.contextPruning.ttl).toBe('90m');
expect(config.agents.defaults.contextPruning.keepLastAssistants).toBe(2);
// Community agents (cuz is not in DEFAULT_DEV_AGENT_IDS) inherit the
// gateway-level default — nemotron, no openai-codex. Codex is gated to
// gateway-level default — DeepSeek, no openai-codex. Codex is gated to
// dev agents via the per-agent override in provisionOpenClawAccount.
expect(config.agents.defaults.model.primary).toBe('openrouter/nvidia/nemotron-3-super-120b-a12b:free');
// Trinity removed 2026-05-03 (deregistered at OpenRouter, 404). Gemini
// entries remain as inert placeholders until GEMINI_API_KEY is reinstated.
expect(config.agents.defaults.model.fallbacks).toEqual(
expect.arrayContaining(['google/gemini-2.5-flash']),
);
expect(config.agents.defaults.model.primary).toBe('openrouter/deepseek-v4-flash');
const communityChain = [config.agents.defaults.model.primary, ...config.agents.defaults.model.fallbacks];
expect(communityChain.some((model) => model.includes('nemotron'))).toBe(false);
expect(config.models.providers.openrouter.models).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'deepseek-v4-flash' }),
]));
// D81: no cross-vendor substitution after the hosted primary fails.
expect(config.agents.defaults.model.fallbacks).toEqual([]);
expect(config.agents.defaults.model.fallbacks).not.toContain('openrouter/arcee-ai/trinity-large-preview:free');
expect(config.agents.defaults.model.fallbacks).not.toContain('openai-codex/gpt-5.4-mini');
expect(config.agents.defaults.model.fallbacks).not.toContain('openai-codex/gpt-5.4');
Expand Down Expand Up @@ -536,6 +541,7 @@ describe('issueLiteLLMVirtualKey never sends the master key to LiteLLM', () => {
});
afterEach(() => {
k8s.__mock.execStdout = '';
k8s.__mock.execHandler = null;
jest.restoreAllMocks();
if (saved.base === undefined) delete process.env.LITELLM_BASE_URL; else process.env.LITELLM_BASE_URL = saved.base;
if (saved.master === undefined) delete process.env.LITELLM_MASTER_KEY; else process.env.LITELLM_MASTER_KEY = saved.master;
Expand Down Expand Up @@ -582,3 +588,143 @@ describe('runtime account log filtering', () => {
},
);
});

describe('TASK-004 community model isolation', () => {
it('keeps the dev per-agent override unchanged when provisioning the community default', async () => {
await provisionAgentRuntime({
runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'theo',
runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null,
});
const payload = k8s.__mock.replaceNamespacedConfigMap.mock.calls.at(-1)[2];
const config = JSON.parse(payload.data['moltbot.json']);
const dev = config.agents.list.find((agent) => agent.id === 'theo');
expect(dev.model).toEqual({
primary: 'openai-codex/gpt-5.4-mini',
fallbacks: [
'openrouter/nvidia/nemotron-3-super-120b-a12b:free',
'google/gemini-2.5-flash', 'google/gemini-2.5-flash-lite', 'google/gemini-2.0-flash',
],
});
expect(config.agents.defaults.model.primary).toBe('openrouter/deepseek-v4-flash');
});

it.each(['claude-code', 'webhook'])('does not provision gateway models for BYO %s', async (runtimeType) => {
const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length;
await expect(provisionAgentRuntime({ runtimeType, instanceId: 'local-claude' }))
.resolves.toMatchObject({ external: true, runtimeType });
expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes);
});
});

describe('community virtual key permits the explicit DeepSeek primary', () => {
const axios = require('axios');
let saved;
beforeEach(() => {
saved = { base: process.env.LITELLM_BASE_URL, master: process.env.LITELLM_MASTER_KEY };
process.env.LITELLM_BASE_URL = 'http://litellm:4000';
process.env.LITELLM_MASTER_KEY = 'sk-master-test';
k8s.__mock.execStdout = 'sk-community-existing';
jest.spyOn(axios, 'get').mockResolvedValue({ data: { info: {
metadata: { agent_id: 'liz' }, models: ['gpt-5.4-mini'],
} } });
jest.spyOn(axios, 'delete').mockResolvedValue({});
jest.spyOn(axios, 'post').mockResolvedValue({ data: { key: 'sk-community-new' } });
});
afterEach(() => {
k8s.__mock.execStdout = '';
k8s.__mock.execHandler = null;
jest.restoreAllMocks();
if (saved.base === undefined) delete process.env.LITELLM_BASE_URL; else process.env.LITELLM_BASE_URL = saved.base;
if (saved.master === undefined) delete process.env.LITELLM_MASTER_KEY; else process.env.LITELLM_MASTER_KEY = saved.master;
});

it('refreshes an old key that lacks the primary alias and permits its wire model id', async () => {
await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-new', staleOwnedKey: 'sk-community-existing' });
expect(axios.delete).not.toHaveBeenCalled();
expect(axios.post).toHaveBeenCalledWith('http://litellm:4000/key/generate', expect.objectContaining({
models: expect.arrayContaining(['deepseek-v4-flash', 'openrouter/deepseek-v4-flash']),
}), expect.anything());
});

it.each(['error', 'missing', 'master'])('preserves the old key and refuses community master fallback on %s issuance', async (mode) => {
if (mode === 'error') axios.post.mockRejectedValue(new Error('unavailable'));
else axios.post.mockResolvedValue({ data: mode === 'master' ? { key: 'sk-master-test' } : {} });
k8s.__mock.execCommands = [];
const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length;
await expect(provisionAgentRuntime({
runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'liz',
runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null,
})).rejects.toThrow('Community model key unavailable');
expect(axios.delete).not.toHaveBeenCalled();
expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes);
expect(k8s.__mock.execCommands.join('\n')).not.toContain('sk-master-test');
});

it.each(['success', 'skip', 'exec', 'no-pod'])('retires an old key only after a confirmed PVC write: %s', async (mode) => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-key-'));
const profilePath = path.join(dir, 'auth-profiles.json');
fs.writeFileSync(profilePath, JSON.stringify({ profiles: { 'openrouter:default': { key: 'sk-community-existing' } } }));
const originalTimer = global.setTimeout;
jest.spyOn(global, 'setTimeout').mockImplementation((fn, delay, ...args) => originalTimer(fn, delay === 10000 ? 0 : delay, ...args));
const podList = k8s.__mock.listNamespacedPod.getMockImplementation();
let keyAtRetirement;
axios.delete.mockImplementation(async () => {
keyAtRetirement = JSON.parse(fs.readFileSync(profilePath, 'utf8')).profiles['openrouter:default'].key;
return {};
});
k8s.__mock.execHandler = (command, stdout, status) => {
if (command[0] !== 'node' || !command[2].includes("store.profiles['openrouter:default'] =")) return undefined;
if (mode === 'exec') { status({ status: 'Failure' }); return Promise.resolve(); }
const target = mode === 'skip' ? path.join(dir, 'missing.json') : profilePath;
const script = command[2].replace('/state/agents/liz/agent/auth-profiles.json', target);
const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' });
stdout.write(result.stdout);
status({ status: result.status === 0 ? 'Success' : 'Failure' });
return Promise.resolve();
};
if (mode === 'no-pod') {
axios.post.mockImplementation(async () => {
k8s.__mock.listNamespacedPod.mockResolvedValue({ body: { items: [] } });
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => { now += 100000; return now; });
return { data: { key: 'sk-community-new' } };
});
}
const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length;
try {
const provision = provisionAgentRuntime({ runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'liz', runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null });
if (mode === 'success') {
await provision;
expect(axios.delete).toHaveBeenCalledTimes(1);
expect(keyAtRetirement).toBe('sk-community-new');
} else {
await expect(provision).rejects.toThrow('Community model key was not written');
expect(axios.delete).not.toHaveBeenCalled();
expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes);
expect(JSON.parse(fs.readFileSync(profilePath, 'utf8')).profiles['openrouter:default'].key).toBe('sk-community-existing');
}
} finally {
k8s.__mock.listNamespacedPod.mockImplementation(podList);
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('does not delete a key owned by another agent during replacement', async () => {
axios.get.mockResolvedValue({ data: { info: { metadata: { agent_id: 'other' }, models: [] } } });
await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-new', staleOwnedKey: null });
expect(axios.delete).not.toHaveBeenCalled();
});

it('reuses an owned key that already allows the explicit primary', async () => {
axios.get.mockResolvedValue({ data: { info: {
metadata: { agent_id: 'liz' }, models: ['gpt-5.4-mini', 'deepseek-v4-flash'],
} } });
await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-existing', staleOwnedKey: null });
expect(axios.post).not.toHaveBeenCalled();
expect(axios.delete).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('globalModelConfigService', () => {
baseUrl: 'https://openrouter.ai/api/v1',
model: '',
});
expect(result.openclaw.communityAgentModel).toEqual({ primary: 'openrouter/deepseek-v4-flash', fallbacks: [] });
expect(result.openclaw.provider).toBe('google');
expect(result.openclaw.model).toBe('google/gemini-2.5-flash');
expect(result.openclaw.fallbackModels).toEqual([
Expand Down
Loading
Loading