Merge pull request #1237 from guyb1/feat/onecli-integration

feat: replace credential proxy with OneCLI gateway for secret injection
This commit is contained in:
gavrielc
2026-03-24 14:55:41 +02:00
committed by GitHub
10 changed files with 150 additions and 79 deletions

View File

@@ -50,7 +50,7 @@ Already configured. Continue.
**Verify:** `git remote -v` should show `origin` → user's repo, `upstream``qwibitai/nanoclaw.git`. **Verify:** `git remote -v` should show `origin` → user's repo, `upstream``qwibitai/nanoclaw.git`.
## 1. Bootstrap (Node.js + Dependencies) ## 1. Bootstrap (Node.js + Dependencies + OneCLI)
Run `bash setup.sh` and parse the status block. Run `bash setup.sh` and parse the status block.
@@ -62,6 +62,34 @@ Run `bash setup.sh` and parse the status block.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. - If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps. - Record PLATFORM and IS_WSL for later steps.
After bootstrap succeeds, install OneCLI and its CLI tool:
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
```bash
export PATH="$HOME/.local/bin:$PATH"
# Persist for future sessions (append to shell profile if not already present)
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Then re-verify with `onecli version`.
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
```bash
onecli config set api-host http://127.0.0.1:10254
```
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
## 2. Check Environment ## 2. Check Environment
Run `npx tsx setup/index.ts --step environment` and parse the status block. Run `npx tsx setup/index.ts --step environment` and parse the status block.
@@ -112,15 +140,47 @@ Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse th
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Claude Authentication (No Script) ## 4. Anthropic Credentials via OneCLI
If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure? NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time.
AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? Check if a secret already exists:
```bash
onecli secrets list
```
**Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat. If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5.
**API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`. AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
### Subscription path
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
Once they have the token, they register it with OneCLI. AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
Then AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
### After either path
Ask them to let you know when done.
**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
## 5. Set Up Channels ## 5. Set Up Channels
@@ -198,7 +258,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each:** **If STATUS=failed, fix each:**
- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 7 - SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4 - CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret)
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 - REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
@@ -207,7 +267,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
## Troubleshooting ## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). **Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), missing channel credentials (re-invoke channel skill).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.

View File

@@ -1,7 +1,7 @@
# NanoClaw Agent Container # NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation # Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:22-slim FROM node:24-slim
# Install system dependencies for Chromium # Install system dependencies for Chromium
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \

10
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "nanoclaw", "name": "nanoclaw",
"version": "1.2.21", "version": "1.2.21",
"dependencies": { "dependencies": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"pino": "^9.6.0", "pino": "^9.6.0",
@@ -786,6 +787,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@onecli-sh/sdk": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz",
"integrity": "sha512-u7PqWROEvTV9f0ADVkjigTrd2AZn3klbPrv7GGpeRHIJpjAxJUdlWqxr5kiGt6qTDKL8t3nq76xr4X2pxTiyBg==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",

View File

@@ -21,6 +21,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"pino": "^9.6.0", "pino": "^9.6.0",

View File

@@ -101,7 +101,7 @@ export async function run(_args: string[]): Promise<void> {
const envFile = path.join(projectRoot, '.env'); const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) { if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8'); const envContent = fs.readFileSync(envFile, 'utf-8');
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) { if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) {
credentials = 'configured'; credentials = 'configured';
} }
} }

View File

@@ -4,9 +4,7 @@ import path from 'path';
import { readEnvFile } from './env.js'; import { readEnvFile } from './env.js';
// Read config values from .env (falls back to process.env). // Read config values from .env (falls back to process.env).
// Secrets (API keys, tokens) are NOT read here — they are loaded only const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL']);
// by the credential proxy (credential-proxy.ts), never exposed to containers.
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
export const ASSISTANT_NAME = export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -47,10 +45,8 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10, 10,
); // 10MB default ); // 10MB default
export const CREDENTIAL_PROXY_PORT = parseInt( export const ONECLI_URL =
process.env.CREDENTIAL_PROXY_PORT || '3001', process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
10,
);
export const IPC_POLL_INTERVAL = 1000; export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max( export const MAX_CONCURRENT_CONTAINERS = Math.max(

View File

@@ -11,10 +11,10 @@ vi.mock('./config.js', () => ({
CONTAINER_IMAGE: 'nanoclaw-agent:latest', CONTAINER_IMAGE: 'nanoclaw-agent:latest',
CONTAINER_MAX_OUTPUT_SIZE: 10485760, CONTAINER_MAX_OUTPUT_SIZE: 10485760,
CONTAINER_TIMEOUT: 1800000, // 30min CONTAINER_TIMEOUT: 1800000, // 30min
CREDENTIAL_PROXY_PORT: 3001,
DATA_DIR: '/tmp/nanoclaw-test-data', DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups', GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min IDLE_TIMEOUT: 1800000, // 30min
ONECLI_URL: 'http://localhost:10254',
TIMEZONE: 'America/Los_Angeles', TIMEZONE: 'America/Los_Angeles',
})); }));
@@ -51,6 +51,15 @@ vi.mock('./mount-security.js', () => ({
validateAdditionalMounts: vi.fn(() => []), validateAdditionalMounts: vi.fn(() => []),
})); }));
// Mock OneCLI SDK
vi.mock('@onecli-sh/sdk', () => ({
OneCLI: class {
applyContainerConfig = vi.fn().mockResolvedValue(true);
createAgent = vi.fn().mockResolvedValue({ id: 'test' });
ensureAgent = vi.fn().mockResolvedValue({ name: 'test', identifier: 'test', created: true });
},
}));
// Create a controllable fake ChildProcess // Create a controllable fake ChildProcess
function createFakeProcess() { function createFakeProcess() {
const proc = new EventEmitter() as EventEmitter & { const proc = new EventEmitter() as EventEmitter & {

View File

@@ -10,25 +10,26 @@ import {
CONTAINER_IMAGE, CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT, CONTAINER_TIMEOUT,
CREDENTIAL_PROXY_PORT,
DATA_DIR, DATA_DIR,
GROUPS_DIR, GROUPS_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
ONECLI_URL,
TIMEZONE, TIMEZONE,
} from './config.js'; } from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
CONTAINER_HOST_GATEWAY,
CONTAINER_RUNTIME_BIN, CONTAINER_RUNTIME_BIN,
hostGatewayArgs, hostGatewayArgs,
readonlyMountArgs, readonlyMountArgs,
stopContainer, stopContainer,
} from './container-runtime.js'; } from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js'; import { OneCLI } from '@onecli-sh/sdk';
import { validateAdditionalMounts } from './mount-security.js'; import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js'; import { RegisteredGroup } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
// Sentinel markers for robust output parsing (must match agent-runner) // Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
@@ -77,7 +78,7 @@ function buildVolumeMounts(
}); });
// Shadow .env so the agent cannot read secrets from the mounted project root. // Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the credential proxy, never exposed to containers. // Credentials are injected by the OneCLI gateway, never exposed to containers.
const envFile = path.join(projectRoot, '.env'); const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) { if (fs.existsSync(envFile)) {
mounts.push({ mounts.push({
@@ -212,30 +213,26 @@ function buildVolumeMounts(
return mounts; return mounts;
} }
function buildContainerArgs( async function buildContainerArgs(
mounts: VolumeMount[], mounts: VolumeMount[],
containerName: string, containerName: string,
): string[] { agentIdentifier?: string,
): Promise<string[]> {
const args: string[] = ['run', '-i', '--rm', '--name', containerName]; const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's // Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`); args.push('-e', `TZ=${TIMEZONE}`);
// Route API traffic through the credential proxy (containers never see real secrets) // OneCLI gateway handles credential injection — containers never see real secrets.
args.push( // The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
'-e', const onecliApplied = await onecli.applyContainerConfig(args, {
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, addHostMapping: false, // Nanoclaw already handles host gateway
); agent: agentIdentifier,
});
// Mirror the host's auth method with a placeholder value. if (onecliApplied) {
// API key mode: SDK sends x-api-key, proxy replaces with real key. logger.info({ containerName }, 'OneCLI gateway config applied');
// OAuth mode: SDK exchanges placeholder token for temp API key,
// proxy injects real OAuth token on that exchange request.
const authMode = detectAuthMode();
if (authMode === 'api-key') {
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
} else { } else {
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials');
} }
// Runtime-specific args for host gateway resolution // Runtime-specific args for host gateway resolution
@@ -278,7 +275,11 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain); const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`; const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName); // Main group uses the default OneCLI agent; others use their own agent.
const agentIdentifier = input.isMain
? undefined
: group.folder.toLowerCase().replace(/_/g, '-');
const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier);
logger.debug( logger.debug(
{ {

View File

@@ -3,7 +3,6 @@
* All runtime-specific logic lives here so swapping runtimes means changing one file. * All runtime-specific logic lives here so swapping runtimes means changing one file.
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os'; import os from 'os';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -11,35 +10,6 @@ import { logger } from './logger.js';
/** The container runtime binary name. */ /** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker'; export const CONTAINER_RUNTIME_BIN = 'docker';
/** Hostname containers use to reach the host machine. */
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
/**
* Address the credential proxy binds to.
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
* Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
* falling back to 0.0.0.0 if the interface isn't found.
*/
export const PROXY_BIND_HOST =
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
function detectProxyBindHost(): string {
if (os.platform() === 'darwin') return '127.0.0.1';
// WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
// Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
// Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
const ifaces = os.networkInterfaces();
const docker0 = ifaces['docker0'];
if (docker0) {
const ipv4 = docker0.find((a) => a.family === 'IPv4');
if (ipv4) return ipv4.address;
}
return '0.0.0.0';
}
/** CLI args needed for the container to resolve the host gateway. */ /** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] { export function hostGatewayArgs(): string[] {
// On Linux, host.docker.internal isn't built-in — add it explicitly // On Linux, host.docker.internal isn't built-in — add it explicitly

View File

@@ -1,15 +1,16 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
CREDENTIAL_PROXY_PORT,
IDLE_TIMEOUT, IDLE_TIMEOUT,
ONECLI_URL,
POLL_INTERVAL, POLL_INTERVAL,
TIMEZONE, TIMEZONE,
TRIGGER_PATTERN, TRIGGER_PATTERN,
} from './config.js'; } from './config.js';
import { startCredentialProxy } from './credential-proxy.js';
import './channels/index.js'; import './channels/index.js';
import { import {
getChannelFactory, getChannelFactory,
@@ -24,7 +25,6 @@ import {
import { import {
cleanupOrphans, cleanupOrphans,
ensureContainerRuntimeRunning, ensureContainerRuntimeRunning,
PROXY_BIND_HOST,
} from './container-runtime.js'; } from './container-runtime.js';
import { import {
getAllChats, getAllChats,
@@ -72,6 +72,27 @@ let messageLoopRunning = false;
const channels: Channel[] = []; const channels: Channel[] = [];
const queue = new GroupQueue(); const queue = new GroupQueue();
const onecli = new OneCLI({ url: ONECLI_URL });
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
if (group.isMain) return;
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
onecli.ensureAgent({ name: group.name, identifier }).then(
(res) => {
logger.info(
{ jid, identifier, created: res.created },
'OneCLI agent ensured',
);
},
(err) => {
logger.debug(
{ jid, identifier, err: String(err) },
'OneCLI agent ensure skipped',
);
},
);
}
function loadState(): void { function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || ''; lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp'); const agentTs = getRouterState('last_agent_timestamp');
@@ -112,6 +133,9 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
// Create group folder // Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
logger.info( logger.info(
{ jid, name: group.name, folder: group.folder }, { jid, name: group.name, folder: group.folder },
'Group registered', 'Group registered',
@@ -474,18 +498,18 @@ async function main(): Promise<void> {
initDatabase(); initDatabase();
logger.info('Database initialized'); logger.info('Database initialized');
loadState(); loadState();
restoreRemoteControl();
// Start credential proxy (containers route API calls through this) // Ensure OneCLI agents exist for all registered groups.
const proxyServer = await startCredentialProxy( // Recovers from missed creates (e.g. OneCLI was down at registration time).
CREDENTIAL_PROXY_PORT, for (const [jid, group] of Object.entries(registeredGroups)) {
PROXY_BIND_HOST, ensureOneCLIAgent(jid, group);
); }
restoreRemoteControl();
// Graceful shutdown handlers // Graceful shutdown handlers
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received'); logger.info({ signal }, 'Shutdown signal received');
proxyServer.close();
await queue.shutdown(10000); await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect(); for (const ch of channels) await ch.disconnect();
process.exit(0); process.exit(0);