mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-24 18:31:31 +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}"
|
||||
|
||||
@@ -51,6 +51,10 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
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(
|
||||
|
||||
@@ -11,10 +11,10 @@ vi.mock('./config.js', () => ({
|
||||
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||
CREDENTIAL_PROXY_PORT: 3001,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
ONECLI_URL: 'http://localhost:10254',
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
@@ -53,21 +53,16 @@ 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`],
|
||||
stopContainer: 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 });
|
||||
},
|
||||
// Mock credential-proxy
|
||||
vi.mock('./credential-proxy.js', () => ({
|
||||
detectAuthMode: vi.fn(() => 'api-key'),
|
||||
}));
|
||||
|
||||
// Create a controllable fake ChildProcess
|
||||
|
||||
+31
-39
@@ -10,26 +10,25 @@ import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
CREDENTIAL_PROXY_PORT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
ONECLI_URL,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
CONTAINER_HOST_GATEWAY,
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
hostGatewayArgs,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
} from './container-runtime.js';
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
import { detectAuthMode } from './credential-proxy.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
@@ -78,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 OneCLI gateway, 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({
|
||||
@@ -223,29 +214,31 @@ function buildVolumeMounts(
|
||||
return mounts;
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
agentIdentifier?: string,
|
||||
): Promise<string[]> {
|
||||
isMain: boolean,
|
||||
): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// OneCLI gateway handles credential injection — containers never see real secrets.
|
||||
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, {
|
||||
addHostMapping: false, // Nanoclaw already handles host gateway
|
||||
agent: agentIdentifier,
|
||||
});
|
||||
if (onecliApplied) {
|
||||
logger.info({ containerName }, 'OneCLI gateway config applied');
|
||||
// Route API traffic through the credential proxy (containers never see real secrets)
|
||||
args.push(
|
||||
'-e',
|
||||
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
|
||||
);
|
||||
|
||||
// Mirror the host's auth method with a placeholder value.
|
||||
// API key mode: SDK sends x-api-key, proxy replaces with real key.
|
||||
// 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 {
|
||||
logger.warn(
|
||||
{ containerName },
|
||||
'OneCLI gateway not reachable — container will have no credentials',
|
||||
);
|
||||
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
|
||||
}
|
||||
|
||||
// Runtime-specific args for host gateway resolution
|
||||
@@ -257,7 +250,14 @@ async 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,15 +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()}`;
|
||||
// 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,
|
||||
);
|
||||
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');
|
||||
|
||||
+89
-41
@@ -3,12 +3,46 @@
|
||||
* All runtime-specific logic lives here so swapping runtimes means changing one file.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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;
|
||||
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. */
|
||||
export function hostGatewayArgs(): string[] {
|
||||
@@ -24,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. */
|
||||
@@ -32,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);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
const mockEnv: Record<string, string> = {};
|
||||
vi.mock('./env.js', () => ({
|
||||
readEnvFile: vi.fn(() => ({ ...mockEnv })),
|
||||
}));
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { startCredentialProxy } from './credential-proxy.js';
|
||||
|
||||
function makeRequest(
|
||||
port: number,
|
||||
options: http.RequestOptions,
|
||||
body = '',
|
||||
): Promise<{
|
||||
statusCode: number;
|
||||
body: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ ...options, hostname: '127.0.0.1', port },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode!,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('credential-proxy', () => {
|
||||
let proxyServer: http.Server;
|
||||
let upstreamServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let upstreamPort: number;
|
||||
let lastUpstreamHeaders: http.IncomingHttpHeaders;
|
||||
|
||||
beforeEach(async () => {
|
||||
lastUpstreamHeaders = {};
|
||||
|
||||
upstreamServer = http.createServer((req, res) => {
|
||||
lastUpstreamHeaders = { ...req.headers };
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
upstreamPort = (upstreamServer.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((r) => proxyServer?.close(() => r()));
|
||||
await new Promise<void>((r) => upstreamServer?.close(() => r()));
|
||||
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
|
||||
});
|
||||
|
||||
async function startProxy(env: Record<string, string>): Promise<number> {
|
||||
Object.assign(mockEnv, env, {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
return (proxyServer.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
it('API-key mode injects x-api-key and strips placeholder', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
|
||||
});
|
||||
|
||||
it('OAuth mode replaces Authorization when container sends one', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/oauth/claude_cli/create_api_key',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer placeholder',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['authorization']).toBe(
|
||||
'Bearer real-oauth-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('OAuth mode does not inject Authorization when container omits it', async () => {
|
||||
proxyPort = await startProxy({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
|
||||
});
|
||||
|
||||
// Post-exchange: container uses x-api-key only, no Authorization header
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'temp-key-from-exchange',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
|
||||
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers', async () => {
|
||||
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
|
||||
|
||||
await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
connection: 'keep-alive',
|
||||
'keep-alive': 'timeout=5',
|
||||
'transfer-encoding': 'chunked',
|
||||
},
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
// Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
|
||||
// its own Connection header (standard HTTP/1.1 behavior), but the client's
|
||||
// custom keep-alive and transfer-encoding must not be forwarded.
|
||||
expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
|
||||
expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 502 when upstream is unreachable', async () => {
|
||||
Object.assign(mockEnv, {
|
||||
ANTHROPIC_API_KEY: 'sk-ant-real-key',
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
|
||||
});
|
||||
proxyServer = await startCredentialProxy(0);
|
||||
proxyPort = (proxyServer.address() as AddressInfo).port;
|
||||
|
||||
const res = await makeRequest(
|
||||
proxyPort,
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/v1/messages',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
'{}',
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.body).toBe('Bad Gateway');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Credential proxy for container isolation.
|
||||
* Containers connect here instead of directly to the Anthropic API.
|
||||
* The proxy injects real credentials so containers never see them.
|
||||
*
|
||||
* Two auth modes:
|
||||
* API key: Proxy injects x-api-key on every request.
|
||||
* OAuth: Container CLI exchanges its placeholder token for a temp
|
||||
* API key via /api/oauth/claude_cli/create_api_key.
|
||||
* Proxy injects real OAuth token on that exchange request;
|
||||
* subsequent requests carry the temp key which is valid as-is.
|
||||
*/
|
||||
import { createServer, Server } from 'http';
|
||||
import { request as httpsRequest } from 'https';
|
||||
import { request as httpRequest, RequestOptions } from 'http';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export type AuthMode = 'api-key' | 'oauth';
|
||||
|
||||
export interface ProxyConfig {
|
||||
authMode: AuthMode;
|
||||
}
|
||||
|
||||
export function startCredentialProxy(
|
||||
port: number,
|
||||
host = '127.0.0.1',
|
||||
): Promise<Server> {
|
||||
const secrets = readEnvFile([
|
||||
'ANTHROPIC_API_KEY',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
]);
|
||||
|
||||
const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
|
||||
const oauthToken =
|
||||
secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const upstreamUrl = new URL(
|
||||
secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
|
||||
);
|
||||
const isHttps = upstreamUrl.protocol === 'https:';
|
||||
const makeRequest = isHttps ? httpsRequest : httpRequest;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const headers: Record<string, string | number | string[] | undefined> =
|
||||
{
|
||||
...(req.headers as Record<string, string>),
|
||||
host: upstreamUrl.host,
|
||||
'content-length': body.length,
|
||||
};
|
||||
|
||||
// Strip hop-by-hop headers that must not be forwarded by proxies
|
||||
delete headers['connection'];
|
||||
delete headers['keep-alive'];
|
||||
delete headers['transfer-encoding'];
|
||||
|
||||
if (authMode === 'api-key') {
|
||||
// API key mode: inject x-api-key on every request
|
||||
delete headers['x-api-key'];
|
||||
headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
// OAuth mode: replace placeholder Bearer token with the real one
|
||||
// only when the container actually sends an Authorization header
|
||||
// (exchange request + auth probes). Post-exchange requests use
|
||||
// x-api-key only, so they pass through without token injection.
|
||||
if (headers['authorization']) {
|
||||
delete headers['authorization'];
|
||||
if (oauthToken) {
|
||||
headers['authorization'] = `Bearer ${oauthToken}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = makeRequest(
|
||||
{
|
||||
hostname: upstreamUrl.hostname,
|
||||
port: upstreamUrl.port || (isHttps ? 443 : 80),
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers,
|
||||
} as RequestOptions,
|
||||
(upRes) => {
|
||||
res.writeHead(upRes.statusCode!, upRes.headers);
|
||||
upRes.pipe(res);
|
||||
},
|
||||
);
|
||||
|
||||
upstream.on('error', (err) => {
|
||||
logger.error(
|
||||
{ err, url: req.url },
|
||||
'Credential proxy upstream error',
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end('Bad Gateway');
|
||||
}
|
||||
});
|
||||
|
||||
upstream.write(body);
|
||||
upstream.end();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
logger.info({ port, host, authMode }, 'Credential proxy started');
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect which auth mode the host is configured for. */
|
||||
export function detectAuthMode(): AuthMode {
|
||||
const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
|
||||
return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
formatOutbound,
|
||||
stripInternalTags,
|
||||
} from './router.js';
|
||||
import { parseTextStyles, parseSignalStyles } from './text-styles.js';
|
||||
import { NewMessage } from './types.js';
|
||||
|
||||
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
||||
@@ -293,259 +292,3 @@ describe('trigger gating (requiresTrigger interaction)', () => {
|
||||
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseTextStyles ---
|
||||
|
||||
describe('parseTextStyles — passthrough channels', () => {
|
||||
it('passes text through unchanged on discord', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'discord')).toBe(md);
|
||||
});
|
||||
|
||||
it('passes text through unchanged on signal (signal uses parseSignalStyles)', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'signal')).toBe(md);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — bold', () => {
|
||||
it('converts **bold** to *bold* on whatsapp', () => {
|
||||
expect(parseTextStyles('**hello**', 'whatsapp')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on telegram', () => {
|
||||
expect(parseTextStyles('say **this** now', 'telegram')).toBe(
|
||||
'say *this* now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on slack', () => {
|
||||
expect(parseTextStyles('**hello**', 'slack')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('does not convert a lone * as bold', () => {
|
||||
expect(parseTextStyles('a * b * c', 'whatsapp')).toBe('a * b * c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — italic', () => {
|
||||
it('converts *italic* to _italic_ on whatsapp', () => {
|
||||
expect(parseTextStyles('say *this* now', 'whatsapp')).toBe(
|
||||
'say _this_ now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts *italic* to _italic_ on telegram', () => {
|
||||
expect(parseTextStyles('*italic*', 'telegram')).toBe('_italic_');
|
||||
});
|
||||
|
||||
it('bold-before-italic: **bold** *italic* → *bold* _italic_', () => {
|
||||
expect(parseTextStyles('**bold** *italic*', 'whatsapp')).toBe(
|
||||
'*bold* _italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — headings', () => {
|
||||
it('converts # heading on whatsapp', () => {
|
||||
expect(parseTextStyles('# Top', 'whatsapp')).toBe('*Top*');
|
||||
});
|
||||
|
||||
it('converts ## heading on telegram', () => {
|
||||
expect(parseTextStyles('## Hello World', 'telegram')).toBe('*Hello World*');
|
||||
});
|
||||
|
||||
it('converts ### heading on telegram', () => {
|
||||
expect(parseTextStyles('### Section', 'telegram')).toBe('*Section*');
|
||||
});
|
||||
|
||||
it('only converts headings at line start', () => {
|
||||
const input = 'not a ## heading in middle';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — links', () => {
|
||||
it('converts [text](url) to text (url) on whatsapp', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'whatsapp')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to text (url) on telegram', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'telegram')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to <url|text> on slack', () => {
|
||||
expect(parseTextStyles('[Click here](https://example.com)', 'slack')).toBe(
|
||||
'<https://example.com|Click here>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — horizontal rules', () => {
|
||||
it('strips --- on telegram', () => {
|
||||
expect(parseTextStyles('above\n---\nbelow', 'telegram')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips *** on whatsapp', () => {
|
||||
expect(parseTextStyles('above\n***\nbelow', 'whatsapp')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — code block protection', () => {
|
||||
it('does not transform **bold** inside fenced code block', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
|
||||
it('does not transform *italic* inside inline code', () => {
|
||||
const input = 'use `*star*` literally';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(input);
|
||||
});
|
||||
|
||||
it('transforms text outside code blocks but not inside', () => {
|
||||
const input = '**bold** and `*code*` and *italic*';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(
|
||||
'*bold* and `*code*` and _italic_',
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms text outside fenced block but not inside', () => {
|
||||
const input = '**bold**\n```\n**raw**\n```\n*italic*';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(
|
||||
'*bold*\n```\n**raw**\n```\n_italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseSignalStyles ---
|
||||
|
||||
describe('parseSignalStyles — basic styles', () => {
|
||||
it('extracts BOLD from **text**', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**hello**');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from *text*', () => {
|
||||
const { text, textStyle } = parseSignalStyles('*hello*');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from _text_', () => {
|
||||
const { text, textStyle } = parseSignalStyles('_hello_');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts STRIKETHROUGH from ~~text~~', () => {
|
||||
const { text, textStyle } = parseSignalStyles('~~hello~~');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([
|
||||
{ style: 'STRIKETHROUGH', start: 0, length: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts MONOSPACE from `inline code`', () => {
|
||||
const { text, textStyle } = parseSignalStyles('`code`');
|
||||
expect(text).toBe('code');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 4 }]);
|
||||
});
|
||||
|
||||
it('extracts BOLD from ## heading and strips marker', () => {
|
||||
const { text, textStyle } = parseSignalStyles('## Hello World');
|
||||
expect(text).toBe('Hello World');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 11 }]);
|
||||
});
|
||||
|
||||
it('no styles for plain text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('just plain text');
|
||||
expect(text).toBe('just plain text');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — mixed content', () => {
|
||||
it('correctly offsets styles in mixed text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('say **hi** now');
|
||||
expect(text).toBe('say hi now');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 4, length: 2 }]);
|
||||
});
|
||||
|
||||
it('handles multiple styles with correct offsets', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**bold** and *italic*');
|
||||
expect(text).toBe('bold and italic');
|
||||
expect(textStyle[0]).toEqual({ style: 'BOLD', start: 0, length: 4 });
|
||||
expect(textStyle[1]).toEqual({ style: 'ITALIC', start: 9, length: 6 });
|
||||
});
|
||||
|
||||
it('strips link markers, no style applied', () => {
|
||||
const { text, textStyle } = parseSignalStyles(
|
||||
'[Click here](https://example.com)',
|
||||
);
|
||||
expect(text).toBe('Click here (https://example.com)');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips horizontal rules', () => {
|
||||
const { text, textStyle } = parseSignalStyles('above\n---\nbelow');
|
||||
expect(text).toBe('above\nbelow');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — code block protection', () => {
|
||||
it('protects fenced code block content with MONOSPACE', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toBe('**not bold**');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 12 }]);
|
||||
});
|
||||
|
||||
it('styles outside block are still processed', () => {
|
||||
const input = '**bold**\n```\nraw code\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toContain('bold');
|
||||
expect(text).toContain('raw code');
|
||||
const boldStyle = textStyle.find((s) => s.style === 'BOLD');
|
||||
const codeStyle = textStyle.find((s) => s.style === 'MONOSPACE');
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(codeStyle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — snake_case guard', () => {
|
||||
it('does not italicise underscores in snake_case', () => {
|
||||
const { text, textStyle } = parseSignalStyles('use snake_case_here');
|
||||
expect(text).toBe('use snake_case_here');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOutbound — channel-aware', () => {
|
||||
it('applies parseTextStyles when channel is provided', () => {
|
||||
expect(formatOutbound('**bold**', 'whatsapp')).toBe('*bold*');
|
||||
});
|
||||
|
||||
it('returns plain stripped text when no channel provided', () => {
|
||||
expect(formatOutbound('**bold**')).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('strips internal tags then applies channel formatting', () => {
|
||||
expect(
|
||||
formatOutbound('<internal>thinking</internal>**done**', 'telegram'),
|
||||
).toBe('*done*');
|
||||
});
|
||||
|
||||
it('signal channel is passthrough — raw markdown preserved for parseSignalStyles', () => {
|
||||
expect(formatOutbound('**bold**', 'signal')).toBe('**bold**');
|
||||
});
|
||||
});
|
||||
|
||||
+2
-5
@@ -49,7 +49,6 @@ import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { ChannelType } from './text-styles.js';
|
||||
import {
|
||||
restoreRemoteControl,
|
||||
startRemoteControl,
|
||||
@@ -687,16 +686,14 @@ async function main(): Promise<void> {
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
const text = formatOutbound(rawText);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: (jid, rawText) => {
|
||||
sendMessage: (jid, text) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
if (!text) return Promise.resolve();
|
||||
return channel.sendMessage(jid, text);
|
||||
},
|
||||
registeredGroups: () => registeredGroups,
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
import { Channel, NewMessage } from './types.js';
|
||||
import { formatLocalTime } from './timezone.js';
|
||||
import { parseTextStyles, ChannelType } from './text-styles.js';
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
if (!s) return '';
|
||||
@@ -29,10 +28,10 @@ export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
export function formatOutbound(rawText: string, channel?: ChannelType): string {
|
||||
export function formatOutbound(rawText: string): string {
|
||||
const text = stripInternalTags(rawText);
|
||||
if (!text) return '';
|
||||
return channel ? parseTextStyles(text, channel) : text;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function routeOutbound(
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* parseTextStyles — convert Claude's Markdown output to channel-native formatting.
|
||||
*
|
||||
* Claude outputs standard Markdown. Each channel has its own text style syntax:
|
||||
* - Signal: passthrough (SignalChannel handles rich text styles natively
|
||||
* via the signal-cli JSON-RPC textStyle param — see parseSignalStyles)
|
||||
* - WhatsApp / Telegram: *bold*, _italic_, no headings, plain links
|
||||
* - Slack: *bold*, _italic_, <url|text> links
|
||||
* - Discord: passthrough (already Markdown)
|
||||
*
|
||||
* Code blocks (fenced and inline) are NEVER transformed by marker substitution.
|
||||
*/
|
||||
|
||||
export type ChannelType =
|
||||
| 'signal'
|
||||
| 'whatsapp'
|
||||
| 'telegram'
|
||||
| 'slack'
|
||||
| 'discord';
|
||||
|
||||
/** Transform Markdown text for the target channel's native format. */
|
||||
export function parseTextStyles(text: string, channel: ChannelType): string {
|
||||
if (!text) return text;
|
||||
|
||||
// Discord and Signal are passthrough — no marker substitution.
|
||||
// Discord is already Markdown; Signal uses parseSignalStyles() for rich text.
|
||||
if (channel === 'discord' || channel === 'signal') return text;
|
||||
|
||||
// Split into protected (code) and unprotected regions, transform only the latter.
|
||||
const segments = splitProtectedRegions(text);
|
||||
return segments
|
||||
.map(({ content, protected: isProtected }) =>
|
||||
isProtected ? content : transformSegment(content, channel),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal rich-text formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SignalTextStyle {
|
||||
/** One of Signal's supported text styles. */
|
||||
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
|
||||
/** Start position in the final message string, in UTF-16 code units. */
|
||||
start: number;
|
||||
/** Length of the styled range, in UTF-16 code units. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude's Markdown into a plain string + Signal textStyle ranges.
|
||||
*
|
||||
* The returned `text` has all markdown markers stripped. The `textStyle`
|
||||
* array uses UTF-16 code-unit offsets (JavaScript's native string indexing),
|
||||
* matching what signal-cli's JSON-RPC `send.textStyle` param expects.
|
||||
*
|
||||
* Supported patterns:
|
||||
* **bold** → BOLD
|
||||
* *italic* → ITALIC
|
||||
* _italic_ → ITALIC
|
||||
* ~~strike~~ → STRIKETHROUGH
|
||||
* `inline code` → MONOSPACE
|
||||
* ```code block``` → MONOSPACE
|
||||
* ## Heading → BOLD (markers stripped)
|
||||
* [text](url) → "text (url)" (no style)
|
||||
* --- → removed
|
||||
*/
|
||||
export function parseSignalStyles(rawText: string): {
|
||||
text: string;
|
||||
textStyle: SignalTextStyle[];
|
||||
} {
|
||||
const textStyle: SignalTextStyle[] = [];
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const s = rawText;
|
||||
const n = s.length;
|
||||
|
||||
function addStyle(
|
||||
style: SignalTextStyle['style'],
|
||||
startOut: number,
|
||||
endOut: number,
|
||||
): void {
|
||||
const length = endOut - startOut;
|
||||
if (length > 0) textStyle.push({ style, start: startOut, length });
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
// ── Fenced code block ```[lang]\n...\n``` ──────────────────────────
|
||||
if (s[i] === '`' && s[i + 1] === '`' && s[i + 2] === '`') {
|
||||
const langNl = s.indexOf('\n', i + 3);
|
||||
if (langNl !== -1) {
|
||||
// Find closing ``` on its own line
|
||||
const closeAt = s.indexOf('\n```', langNl);
|
||||
if (closeAt !== -1) {
|
||||
const content = s.slice(langNl + 1, closeAt);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
// Advance past \n``` + optional trailing newline
|
||||
const afterClose = s.indexOf('\n', closeAt + 4);
|
||||
i = afterClose !== -1 ? afterClose + 1 : n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Malformed fence — copy literally
|
||||
out += s[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Inline code `text` ────────────────────────────────────────────
|
||||
if (s[i] === '`') {
|
||||
const end = s.indexOf('`', i + 1);
|
||||
const nl = s.indexOf('\n', i + 1);
|
||||
if (end !== -1 && (nl === -1 || end < nl)) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bold **text** ─────────────────────────────────────────────────
|
||||
if (s[i] === '*' && s[i + 1] === '*' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('**', i + 2);
|
||||
if (end !== -1 && s[end - 1] !== ' ') {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strikethrough ~~text~~ ────────────────────────────────────────
|
||||
if (s[i] === '~' && s[i + 1] === '~' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('~~', i + 2);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('STRIKETHROUGH', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic *text* (single star, not part of **) ─────────────────
|
||||
if (
|
||||
s[i] === '*' &&
|
||||
s[i + 1] !== '*' &&
|
||||
s[i + 1] !== ' ' &&
|
||||
s[i + 1] !== undefined
|
||||
) {
|
||||
const end = findClosingStar(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic _text_ (only at word boundaries) ──────────────────────
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && s[i + 1] !== ' ' && s[i + 1]) {
|
||||
// Guard against snake_case: only treat as italic when preceded by a
|
||||
// non-word character (or start of string).
|
||||
const prevChar = i > 0 ? s[i - 1] : '';
|
||||
if (!/\w/.test(prevChar)) {
|
||||
const end = findClosingUnderscore(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ATX Heading ## text → text (as BOLD) ─────────────────────────
|
||||
if ((i === 0 || s[i - 1] === '\n') && s[i] === '#') {
|
||||
let j = i;
|
||||
while (j < n && s[j] === '#') j++;
|
||||
if (j < n && s[j] === ' ') {
|
||||
const lineEnd = s.indexOf('\n', j + 1);
|
||||
const headingText =
|
||||
lineEnd !== -1 ? s.slice(j + 1, lineEnd) : s.slice(j + 1);
|
||||
const startOut = out.length;
|
||||
out += headingText;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
if (lineEnd !== -1) {
|
||||
out += '\n';
|
||||
i = lineEnd + 1;
|
||||
} else i = n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Links [text](url) → text (url) ───────────────────────────────
|
||||
if (s[i] === '[') {
|
||||
const closeBracket = s.indexOf(']', i + 1);
|
||||
if (closeBracket !== -1 && s[closeBracket + 1] === '(') {
|
||||
const closeParen = s.indexOf(')', closeBracket + 2);
|
||||
if (closeParen !== -1) {
|
||||
const linkText = s.slice(i + 1, closeBracket);
|
||||
const url = s.slice(closeBracket + 2, closeParen);
|
||||
out += `${linkText} (${url})`;
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Horizontal rule --- / *** / ___ ──────────────────────────────
|
||||
if (i === 0 || s[i - 1] === '\n') {
|
||||
const hrMatch = /^(-{3,}|\*{3,}|_{3,}) *(\n|$)/.exec(s.slice(i));
|
||||
if (hrMatch) {
|
||||
i += hrMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default: copy character, preserving surrogate pairs ───────────
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff && i + 1 < n) {
|
||||
out += s[i] + s[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += s[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: out, textStyle };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for parseSignalStyles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Find the position of a closing single `*` that isn't part of `**`. */
|
||||
function findClosingStar(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1; // italics don't span lines
|
||||
if (s[i] === '*' && s[i + 1] !== '*' && s[i - 1] !== ' ') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Find the closing `_` that isn't part of `__` and is at a word boundary. */
|
||||
function findClosingUnderscore(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1;
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && !/\w/.test(s[i + 1] ?? '')) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker-substitution helpers (WhatsApp / Telegram / Slack)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Segment {
|
||||
content: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into alternating unprotected/protected segments.
|
||||
* Protected = fenced code blocks (```...```) and inline code (`...`).
|
||||
*/
|
||||
function splitProtectedRegions(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]+`/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = CODE_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
content: text.slice(lastIndex, match.index),
|
||||
protected: false,
|
||||
});
|
||||
}
|
||||
segments.push({ content: match[0], protected: true });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ content: text.slice(lastIndex), protected: false });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ content: text, protected: false }];
|
||||
}
|
||||
|
||||
/** Apply marker-substitution transformations to a non-code segment. */
|
||||
function transformSegment(text: string, channel: ChannelType): string {
|
||||
let t = text;
|
||||
|
||||
// Order matters: italic before bold.
|
||||
// The italic regex won't match **bold** (it requires the char after the opening *
|
||||
// to be a non-* non-space), so running italic first is safe. If we ran bold
|
||||
// first (**bold** → *bold*), the italic step would immediately re-convert *bold*
|
||||
// to _bold_, producing wrong output.
|
||||
|
||||
// 1. Italic: *text* → _text_ (whatsapp/telegram/slack use _)
|
||||
t = t.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
|
||||
|
||||
// 2. Bold: **text** → *text* (whatsapp/telegram/slack use single *)
|
||||
t = t.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
|
||||
|
||||
// 3. Headings: ## Title → *Title* (any level, line-start only)
|
||||
t = t.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
||||
|
||||
// 4. Links
|
||||
if (channel === 'slack') {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
||||
} else {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
|
||||
}
|
||||
|
||||
// 5. Horizontal rules: strip them
|
||||
t = t.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
|
||||
|
||||
return t;
|
||||
}
|
||||
Reference in New Issue
Block a user