Compare commits

..

22 Commits

Author SHA1 Message Date
gavrielc 2c3300bde9 Merge pull request #1609 from qwibitai/fix/apple-container-proxy-bind
fix: require CREDENTIAL_PROXY_HOST for Apple Container networking
2026-04-03 12:31:50 +03:00
Gavriel Cohen c0b58bd7ae fix: require CREDENTIAL_PROXY_HOST for Apple Container networking
bridge100 only exists while containers run, but the credential proxy must
start before any container. Binding to the bridge IP fails with EADDRNOTAVAIL
on cold boot and first-time setup.

Fail immediately with a clear error pointing to /convert-to-apple-container,
which guides users through setting CREDENTIAL_PROXY_HOST and optionally
configuring a macOS firewall rule on untrusted networks.

Co-Authored-By: MrBlaise <3867275+MrBlaise@users.noreply.github.com>
Co-Authored-By: lbsnrs <47463+lbsnrs@users.noreply.github.com>
Co-Authored-By: spencer-whitman <28708638+spencer-whitman@users.noreply.github.com>
Co-Authored-By: lazure-ocean <43110733+lazure-ocean@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:29:13 +03:00
gavrielc e3e6ccdbd9 Merge pull request #1523 from qwibitai/apple-container-fixes
fix: Apple Container networking and .env mount
2026-03-29 00:08:46 +03:00
gavrielc 9fc60f6af2 security: bind credential proxy to bridge IP, not 0.0.0.0
Binding to 0.0.0.0 exposed the credential proxy (which holds API
keys/OAuth tokens) to the entire local network. Now binds to the
bridge interface IP (same as CONTAINER_HOST_GATEWAY) so only Apple
Container VMs can reach it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:02:32 +03:00
gavrielc 5c56d4564d fix: Apple Container networking and .env mount
- Detect host gateway via bridge100/bridge0 interface instead of
  hardcoded host.docker.internal (not available in Apple Container VMs)
- Bind credential proxy to 0.0.0.0 so VMs can reach it via bridge network
- Export PROXY_BIND_HOST for credential proxy to use
- Remove /dev/null .env shadow mount — Apple Container only supports
  directory mounts; entrypoint handles .env shadowing via mount --bind

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:59:11 +03:00
gavrielc d0a61c6f57 merge: catch up with upstream main
Picks up main's changes while preserving Apple Container runtime:
- Built-in logger replacing pino/pino-pretty
- Removed unused deps (yaml, zod, @vitest/coverage-v8)
- stopContainer bug fix (exec wrapper removed)
- Kept branch's credential proxy (not OneCLI) and Apple Container
  runtime commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:42:15 +03:00
gavrielc b341bc1585 chore: remove direct pino/pino-pretty dependency
Pino was replaced with a built-in logger on main. For branches
with baileys (WhatsApp), pino resolves as a transitive dependency
of @whiskeysockets/baileys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:39:40 +03:00
github-actions[bot] 51e776b286 Merge branch 'main' into skill/apple-container 2026-03-14 15:24:09 +00:00
github-actions[bot] 5a16f98838 Merge branch 'main' into skill/apple-container 2026-03-14 13:16:50 +00:00
github-actions[bot] ed02382e68 Merge branch 'main' into skill/apple-container 2026-03-13 11:59:37 +00:00
github-actions[bot] efab26c97d Merge branch 'main' into skill/apple-container 2026-03-13 11:59:04 +00:00
github-actions[bot] 0432d13617 Merge branch 'main' into skill/apple-container 2026-03-11 10:30:39 +00:00
github-actions[bot] 79ddc47703 Merge branch 'main' into skill/apple-container 2026-03-11 10:25:35 +00:00
github-actions[bot] 90d38388ad Merge branch 'main' into skill/apple-container 2026-03-10 20:59:38 +00:00
github-actions[bot] 5d226ba56c Merge branch 'main' into skill/apple-container 2026-03-10 20:52:02 +00:00
github-actions[bot] 6c8216e255 Merge branch 'main' into skill/apple-container 2026-03-10 20:39:54 +00:00
github-actions[bot] b66b123886 Merge branch 'main' into skill/apple-container 2026-03-10 00:25:36 +00:00
gavrielc b0c2c835ff Merge commit '4cdd09c' into rebuild-fork 2026-03-10 01:15:20 +02:00
gavrielc 4cdd09c45c Merge remote-tracking branch 'origin/main' into skill/apple-container
# Conflicts:
#	src/container-runner.ts
2026-03-09 23:20:34 +02:00
gavrielc 2f1933775c Merge remote-tracking branch 'origin/main' into skill/apple-container 2026-03-09 00:07:58 +02:00
gavrielc 7c04dafa3d Merge remote-tracking branch 'origin/main' into skill/apple-container 2026-03-08 23:24:40 +02:00
gavrielc 0161ba508a skill/apple-container: switch runtime from Docker to Apple Container
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:18:06 +02:00
9 changed files with 201 additions and 121 deletions
+4 -5
View File
@@ -7,7 +7,6 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
@@ -55,14 +54,14 @@ RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/
# Container input (prompt, group info) is passed via stdin JSON.
# Credentials are injected by the host's credential proxy — never passed here.
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv.
RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node
# Switch to non-root user (required for --dangerously-skip-permissions)
USER node
# Set working directory to group workspace
WORKDIR /workspace/group
+1 -1
View File
@@ -8,7 +8,7 @@ cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
+1 -1
View File
@@ -101,7 +101,7 @@ export async function run(_args: string[]): Promise<void> {
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
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';
}
}
+8 -3
View File
@@ -5,9 +5,12 @@ import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
// Secrets (API keys, tokens) are NOT read here — they are loaded only
// by the credential proxy (credential-proxy.ts), never exposed to containers.
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'TZ']);
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'TZ',
]);
export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -52,6 +55,8 @@ export const CREDENTIAL_PROXY_PORT = parseInt(
process.env.CREDENTIAL_PROXY_PORT || '3001',
10,
);
export const ONECLI_URL =
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
export const MAX_MESSAGES_PER_PROMPT = Math.max(
1,
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
+1 -1
View File
@@ -53,7 +53,7 @@ vi.mock('./mount-security.js', () => ({
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
CONTAINER_RUNTIME_BIN: 'container',
CONTAINER_HOST_GATEWAY: 'host.docker.internal',
hostGatewayArgs: () => [],
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
+12 -12
View File
@@ -77,16 +77,8 @@ function buildVolumeMounts(
readonly: true,
});
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the credential proxy, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// .env shadowing is handled inside the container entrypoint via mount --bind
// (Apple Container only supports directory mounts, not file mounts like /dev/null)
// Main also gets its group folder as the working directory
mounts.push({
@@ -225,6 +217,7 @@ function buildVolumeMounts(
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
isMain: boolean,
): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
@@ -257,7 +250,14 @@ function buildContainerArgs(
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
args.push('--user', `${hostUid}:${hostGid}`);
if (isMain) {
// Main containers start as root so the entrypoint can mount --bind
// to shadow .env. Privileges are dropped via setpriv in entrypoint.sh.
args.push('-e', `RUN_UID=${hostUid}`);
args.push('-e', `RUN_GID=${hostGid}`);
} else {
args.push('--user', `${hostUid}:${hostGid}`);
}
args.push('-e', 'HOME=/home/node');
}
@@ -288,7 +288,7 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName);
const containerArgs = buildContainerArgs(mounts, containerName, input.isMain);
logger.debug(
{
+58 -24
View File
@@ -32,9 +32,12 @@ beforeEach(() => {
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns -v flag with :ro suffix', () => {
it('returns --mount flag with type=bind and readonly', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
});
});
@@ -42,14 +45,18 @@ describe('stopContainer', () => {
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
{ stdio: 'pipe' },
);
});
it('rejects names with shell metacharacters', () => {
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
expect(() => stopContainer('foo; rm -rf /')).toThrow(
'Invalid container name',
);
expect(() => stopContainer('foo$(whoami)')).toThrow(
'Invalid container name',
);
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
expect(mockExecSync).not.toHaveBeenCalled();
});
@@ -64,18 +71,37 @@ describe('ensureContainerRuntimeRunning', () => {
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(logger.debug).toHaveBeenCalledWith(
'Container runtime already running',
);
});
it('throws when docker info fails', () => {
it('auto-starts when system status fails', () => {
// First call (system status) fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('Cannot connect to the Docker daemon');
throw new Error('not running');
});
// Second call (system start) succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(2);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} system start`,
{ stdio: 'pipe', timeout: 30000 },
);
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
});
it('throws when both status and start fail', () => {
mockExecSync.mockImplementation(() => {
throw new Error('failed');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
@@ -88,36 +114,40 @@ describe('ensureContainerRuntimeRunning', () => {
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce(
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
);
it('stops orphaned nanoclaw containers from JSON output', () => {
// Apple Container ls returns JSON
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
{ status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } },
{ status: 'running', configuration: { id: 'nanoclaw-group3-333' } },
{ status: 'running', configuration: { id: 'other-container' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ps + 2 stop calls
// ls + 2 stop calls (only running nanoclaw- containers)
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenNthCalledWith(
3,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
{ stdio: 'pipe' },
);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('');
mockExecSync.mockReturnValueOnce('[]');
cleanupOrphans();
@@ -125,9 +155,9 @@ describe('cleanupOrphans', () => {
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ps fails', () => {
it('warns and continues when ls fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('docker not available');
throw new Error('container not available');
});
cleanupOrphans(); // should not throw
@@ -139,7 +169,11 @@ describe('cleanupOrphans', () => {
});
it('continues stopping remaining containers when one stop fails', () => {
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
+82 -64
View File
@@ -9,35 +9,39 @@ import os from 'os';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';
export const CONTAINER_RUNTIME_BIN = 'container';
/** Hostname containers use to reach the host machine. */
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
/**
* IP address containers use to reach the host machine.
* Apple Container VMs use a bridge network (192.168.64.x); the host is at the gateway.
* Detected from the bridge0 interface, falling back to 192.168.64.1.
*/
export const CONTAINER_HOST_GATEWAY = detectHostGateway();
function detectHostGateway(): string {
// Apple Container on macOS: containers reach the host via the bridge network gateway
const ifaces = os.networkInterfaces();
const bridge = ifaces['bridge100'] || ifaces['bridge0'];
if (bridge) {
const ipv4 = bridge.find((a) => a.family === 'IPv4');
if (ipv4) return ipv4.address;
}
// Fallback: Apple Container's default gateway
return '192.168.64.1';
}
/**
* 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.
* Must be set via CREDENTIAL_PROXY_HOST in .env — there is no safe default
* for Apple Container because bridge100 only exists while containers run,
* but the proxy must start before any container.
* The /convert-to-apple-container skill sets this during setup.
*/
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';
export const PROXY_BIND_HOST = process.env.CREDENTIAL_PROXY_HOST;
if (!PROXY_BIND_HOST) {
throw new Error(
'CREDENTIAL_PROXY_HOST is not set in .env. Run /convert-to-apple-container to configure.',
);
}
/** CLI args needed for the container to resolve the host gateway. */
@@ -54,7 +58,10 @@ export function readonlyMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
return [
'--mount',
`type=bind,source=${hostPath},target=${containerPath},readonly`,
];
}
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
@@ -62,57 +69,68 @@ export function stopContainer(name: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid container name: ${name}`);
}
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
execSync(`${CONTAINER_RUNTIME_BIN} stop ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
logger.debug('Container runtime already running');
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Docker is installed and running ║',
);
console.error(
'║ 2. Run: docker info ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start', {
cause: err,
});
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, {
stdio: 'pipe',
timeout: 30000,
});
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Apple Container is installed ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const orphans = output.trim().split('\n').filter(Boolean);
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] =
JSON.parse(output || '[]');
const orphans = containers
.filter(
(c) =>
c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'),
)
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
stopContainer(name);
+34 -10
View File
@@ -1,18 +1,19 @@
import fs from 'fs';
import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import {
ASSISTANT_NAME,
CREDENTIAL_PROXY_PORT,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import { startCredentialProxy } from './credential-proxy.js';
import './channels/index.js';
import {
getChannelFactory,
@@ -27,7 +28,6 @@ import {
import {
cleanupOrphans,
ensureContainerRuntimeRunning,
PROXY_BIND_HOST,
} from './container-runtime.js';
import {
getAllChats,
@@ -76,6 +76,27 @@ let messageLoopRunning = false;
const channels: Channel[] = [];
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 {
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
@@ -157,6 +178,9 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
logger.info(
{ jid, name: group.name, folder: group.folder },
'Group registered',
@@ -527,18 +551,18 @@ async function main(): Promise<void> {
initDatabase();
logger.info('Database initialized');
loadState();
restoreRemoteControl();
// Start credential proxy (containers route API calls through this)
const proxyServer = await startCredentialProxy(
CREDENTIAL_PROXY_PORT,
PROXY_BIND_HOST,
);
// Ensure OneCLI agents exist for all registered groups.
// Recovers from missed creates (e.g. OneCLI was down at registration time).
for (const [jid, group] of Object.entries(registeredGroups)) {
ensureOneCLIAgent(jid, group);
}
restoreRemoteControl();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
proxyServer.close();
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);