mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c3300bde9 | |||
| c0b58bd7ae | |||
| e3e6ccdbd9 | |||
| 9fc60f6af2 | |||
| 5c56d4564d | |||
| d0a61c6f57 | |||
| b341bc1585 | |||
| 51e776b286 | |||
| 5a16f98838 | |||
| ed02382e68 | |||
| efab26c97d | |||
| 0432d13617 | |||
| 79ddc47703 | |||
| 90d38388ad | |||
| 5d226ba56c | |||
| 6c8216e255 | |||
| b66b123886 | |||
| b0c2c835ff | |||
| 4cdd09c45c | |||
| 2f1933775c | |||
| 7c04dafa3d | |||
| 0161ba508a |
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user