Compare commits

..

6 Commits

Author SHA1 Message Date
gavrielc 3824f467e4 merge: catch up with upstream main
Picks up main's changes while preserving native credential proxy:
- Built-in logger replacing pino/pino-pretty
- Removed unused deps (yaml, zod, @vitest/coverage-v8)
- CLAUDE.md template copy fix (#1391)
- MAX_MESSAGES_PER_PROMPT config
- Kept credential proxy (not OneCLI) for credential injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:50:01 +03:00
gavrielc a674f5f68a 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:40:07 +03:00
github-actions[bot] aa55f0513d Merge branch 'main' into skill/native-credential-proxy 2026-03-24 16:05:14 +00:00
github-actions[bot] 0a144bc799 Merge branch 'main' into skill/native-credential-proxy 2026-03-24 15:56:06 +00:00
github-actions[bot] 8baee0519e Merge branch 'main' into skill/native-credential-proxy 2026-03-24 15:55:50 +00:00
gavrielc 49256759b8 skill: replace OneCLI gateway with native credential proxy
Revert OneCLI integration and add built-in credential proxy that reads
API key or OAuth token from .env, injecting credentials into container
API requests without exposing secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:52:58 +02:00
11 changed files with 392 additions and 592 deletions
-100
View File
@@ -556,106 +556,6 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n');
}
// --- Slash command handling ---
// Only known session slash commands are handled here. This prevents
// accidental interception of user prompts that happen to start with '/'.
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
const trimmedPrompt = prompt.trim();
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined;
let compactBoundarySeen = false;
let hadError = false;
let resultEmitted = false;
try {
for await (const message of query({
prompt: trimmedPrompt,
options: {
cwd: '/workspace/group',
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
},
},
})) {
const msgType = message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') {
slashSessionId = message.session_id;
log(`Session after slash command: ${slashSessionId}`);
}
// Observe compact_boundary to confirm compaction completed
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
compactBoundarySeen = true;
log('Compact boundary observed — compaction completed');
}
if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype;
const textResult = 'result' in message ? (message as { result?: string }).result : null;
if (resultSubtype?.startsWith('error')) {
hadError = true;
writeOutput({
status: 'error',
result: null,
error: textResult || 'Session command failed.',
newSessionId: slashSessionId,
});
} else {
writeOutput({
status: 'success',
result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId,
});
}
resultEmitted = true;
}
}
} catch (err) {
hadError = true;
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Slash command error: ${errorMsg}`);
writeOutput({ status: 'error', result: null, error: errorMsg });
}
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
// Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) {
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
}
// Only emit final session marker if no result was emitted yet and no error occurred
if (!resultEmitted && !hadError) {
writeOutput({
status: 'success',
result: compactBoundarySeen
? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId,
});
} else if (!hadError) {
// Emit session-only marker so host updates session tracking
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
}
return;
}
// --- End slash command handling ---
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
+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|ONECLI_URL)=/m.test(envContent)) {
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
credentials = 'configured';
}
}
+7 -8
View File
@@ -5,12 +5,9 @@ import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'TZ',
]);
// 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']);
export const ASSISTANT_NAME =
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
@@ -51,8 +48,10 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10,
); // 10MB default
export const ONECLI_URL =
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
export const CREDENTIAL_PROXY_PORT = parseInt(
process.env.CREDENTIAL_PROXY_PORT || '3001',
10,
);
export const MAX_MESSAGES_PER_PROMPT = Math.max(
1,
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
+5 -10
View File
@@ -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',
}));
@@ -54,20 +54,15 @@ vi.mock('./mount-security.js', () => ({
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
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
+21 -29
View File
@@ -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---';
@@ -79,7 +78,7 @@ function buildVolumeMounts(
});
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the OneCLI gateway, never exposed to containers.
// Credentials are injected by the credential proxy, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
@@ -223,29 +222,30 @@ function buildVolumeMounts(
return mounts;
}
async function buildContainerArgs(
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
agentIdentifier?: string,
): Promise<string[]> {
): 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
@@ -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);
logger.debug(
{
+30
View File
@@ -3,6 +3,7 @@
* 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';
@@ -10,6 +11,35 @@ import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';
/** Hostname containers use to reach the host machine. */
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
/**
* Address the credential proxy binds to.
* Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback.
* Docker (Linux): bind to the docker0 bridge IP so only containers can reach it,
* falling back to 0.0.0.0 if the interface isn't found.
*/
export const PROXY_BIND_HOST =
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
function detectProxyBindHost(): string {
if (os.platform() === 'darwin') return '127.0.0.1';
// WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct.
// Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd.
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
// Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0
const ifaces = os.networkInterfaces();
const docker0 = ifaces['docker0'];
if (docker0) {
const ipv4 = docker0.find((a) => a.family === 'IPv4');
if (ipv4) return ipv4.address;
}
return '0.0.0.0';
}
/** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] {
// On Linux, host.docker.internal isn't built-in — add it explicitly
+192
View File
@@ -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');
});
});
+125
View File
@@ -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 -87
View File
@@ -1,19 +1,18 @@
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,
@@ -28,6 +27,7 @@ import {
import {
cleanupOrphans,
ensureContainerRuntimeRunning,
PROXY_BIND_HOST,
} from './container-runtime.js';
import {
getAllChats,
@@ -60,7 +60,6 @@ import {
loadSenderAllowlist,
shouldDropMessage,
} from './sender-allowlist.js';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -77,27 +76,6 @@ 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');
@@ -179,9 +157,6 @@ 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',
@@ -238,33 +213,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
if (missedMessages.length === 0) return true;
// --- Session command interception (before trigger check) ---
const cmdResult = await handleSessionCommand({
missedMessages,
isMainGroup,
groupName: group.name,
triggerPattern: getTriggerPattern(group.trigger),
timezone: TIMEZONE,
deps: {
sendMessage: (text) => channel.sendMessage(chatJid, text),
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
closeStdin: () => queue.closeStdin(chatJid),
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
formatMessages,
canSenderInteract: (msg) => {
const hasTrigger = getTriggerPattern(group.trigger).test(msg.content.trim());
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
return isMainGroup || !reqTrigger || (hasTrigger && (
msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
));
},
},
});
if (cmdResult.handled) return cmdResult.success;
// --- End session command interception ---
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
@@ -274,9 +222,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) {
return true;
}
if (!hasTrigger) return true;
}
const prompt = formatMessages(missedMessages, TIMEZONE);
@@ -493,28 +439,6 @@ async function startMessageLoop(): Promise<void> {
}
const isMainGroup = group.isMain === true;
// --- Session command interception (message loop) ---
// Scan ALL messages in the batch for a session command.
const loopCmdMsg = groupMessages.find(
(m) => extractSessionCommand(m.content, getTriggerPattern(group.trigger)) !== null,
);
if (loopCmdMsg) {
// Only close active container if the sender is authorized — otherwise an
// untrusted user could kill in-flight work by sending /compact (DoS).
// closeStdin no-ops internally when no container is active.
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
queue.closeStdin(chatJid);
}
// Enqueue so processGroupMessages handles auth + cursor advancement.
// Don't pipe via IPC — slash commands need a fresh container with
// string prompt (not MessageStream) for SDK recognition.
queue.enqueueMessageCheck(chatJid);
continue;
}
// --- End session command interception ---
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
@@ -603,18 +527,18 @@ async function main(): Promise<void> {
initDatabase();
logger.info('Database initialized');
loadState();
// 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();
// Start credential proxy (containers route API calls through this)
const proxyServer = await startCredentialProxy(
CREDENTIAL_PROXY_PORT,
PROXY_BIND_HOST,
);
// 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);
-214
View File
@@ -1,214 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import type { NewMessage } from './types.js';
import type { SessionCommandDeps } from './session-commands.js';
describe('extractSessionCommand', () => {
const trigger = /^@Andy\b/i;
it('detects bare /compact', () => {
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
});
it('detects /compact with trigger prefix', () => {
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
});
it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
it('rejects partial matches', () => {
expect(extractSessionCommand('/compaction', trigger)).toBeNull();
});
it('rejects regular messages', () => {
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
});
it('handles whitespace', () => {
expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
});
it('is case-sensitive for the command', () => {
expect(extractSessionCommand('/Compact', trigger)).toBeNull();
});
});
describe('isSessionCommandAllowed', () => {
it('allows main group regardless of sender', () => {
expect(isSessionCommandAllowed(true, false)).toBe(true);
});
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
expect(isSessionCommandAllowed(false, true)).toBe(true);
});
it('denies untrusted sender in non-main group', () => {
expect(isSessionCommandAllowed(false, false)).toBe(false);
});
it('allows trusted sender in main group', () => {
expect(isSessionCommandAllowed(true, true)).toBe(true);
});
});
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
return {
id: 'msg-1',
chat_jid: 'group@test',
sender: 'user@test',
sender_name: 'User',
content,
timestamp: '100',
...overrides,
};
}
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
return {
sendMessage: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
runAgent: vi.fn().mockResolvedValue('success'),
closeStdin: vi.fn(),
advanceCursor: vi.fn(),
formatMessages: vi.fn().mockReturnValue('<formatted>'),
canSenderInteract: vi.fn().mockReturnValue(true),
...overrides,
};
}
const trigger = /^@Andy\b/i;
describe('handleSessionCommand', () => {
it('returns handled:false when no session command found', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('hello')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result.handled).toBe(false);
});
it('handles authorized /compact in main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('silently consumes denied command when sender cannot interact', async () => {
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('processes pre-compact messages before /compact', async () => {
const deps = makeDeps();
const msgs = [
makeMsg('summarize this', { timestamp: '99' }),
makeMsg('/compact', { timestamp: '100' }),
];
const result = await handleSessionCommand({
missedMessages: msgs,
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
// Two runAgent calls: pre-compact + /compact
expect(deps.runAgent).toHaveBeenCalledTimes(2);
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
});
it('allows is_from_me sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: true })],
isMainGroup: false,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
});
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
// runAgent resolves 'error' but callback never gets status: 'error'
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
await onOutput({ status: 'success', result: null });
return 'error';
})});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
});
it('returns success:false on pre-compact failure with no output', async () => {
const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
const msgs = [
makeMsg('summarize this', { timestamp: '99' }),
makeMsg('/compact', { timestamp: '100' }),
];
const result = await handleSessionCommand({
missedMessages: msgs,
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: false });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
});
});
-143
View File
@@ -1,143 +0,0 @@
import type { NewMessage } from './types.js';
import { logger } from './logger.js';
/**
* Extract a session slash command from a message, stripping the trigger prefix if present.
* Returns the slash command (e.g., '/compact') or null if not a session command.
*/
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
let text = content.trim();
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
return null;
}
/**
* Check if a session command sender is authorized.
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
*/
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
return isMainGroup || isFromMe;
}
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
export interface AgentResult {
status: 'success' | 'error';
result?: string | object | null;
}
/** Dependencies injected by the orchestrator. */
export interface SessionCommandDeps {
sendMessage: (text: string) => Promise<void>;
setTyping: (typing: boolean) => Promise<void>;
runAgent: (
prompt: string,
onOutput: (result: AgentResult) => Promise<void>,
) => Promise<'success' | 'error'>;
closeStdin: () => void;
advanceCursor: (timestamp: string) => void;
formatMessages: (msgs: NewMessage[], timezone: string) => string;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
}
function resultToText(result: string | object | null | undefined): string {
if (!result) return '';
const raw = typeof result === 'string' ? result : JSON.stringify(result);
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
/**
* Handle session command interception in processGroupMessages.
* Scans messages for a session command, handles auth + execution.
* Returns { handled: true, success } if a command was found; { handled: false } otherwise.
* success=false means the caller should retry (cursor was not advanced).
*/
export async function handleSessionCommand(opts: {
missedMessages: NewMessage[];
isMainGroup: boolean;
groupName: string;
triggerPattern: RegExp;
timezone: string;
deps: SessionCommandDeps;
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
const cmdMsg = missedMessages.find(
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
);
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
if (!command || !cmdMsg) return { handled: false };
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
// DENIED: send denial if the sender would normally be allowed to interact,
// then silently consume the command by advancing the cursor past it.
// Trade-off: other messages in the same batch are also consumed (cursor is
// a high-water mark). Acceptable for this narrow edge case.
if (deps.canSenderInteract(cmdMsg)) {
await deps.sendMessage('Session commands require admin access.');
}
deps.advanceCursor(cmdMsg.timestamp);
return { handled: true, success: true };
}
// AUTHORIZED: process pre-compact messages first, then run the command
logger.info({ group: groupName, command }, 'Session command');
const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
// Send pre-compact messages to the agent so they're in the session context.
if (preCompactMsgs.length > 0) {
const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
let hadPreError = false;
let preOutputSent = false;
const preResult = await deps.runAgent(prePrompt, async (result) => {
if (result.status === 'error') hadPreError = true;
const text = resultToText(result.result);
if (text) {
await deps.sendMessage(text);
preOutputSent = true;
}
// Close stdin on session-update marker — emitted after query completes,
// so all results (including multi-result runs) are already written.
if (result.status === 'success' && result.result === null) {
deps.closeStdin();
}
});
if (preResult === 'error' || hadPreError) {
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
if (preOutputSent) {
// Output was already sent — don't retry or it will duplicate.
// Advance cursor past pre-compact messages, leave command pending.
deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
return { handled: true, success: true };
}
return { handled: true, success: false };
}
}
// Forward the literal slash command as the prompt (no XML formatting)
await deps.setTyping(true);
let hadCmdError = false;
const cmdOutput = await deps.runAgent(command, async (result) => {
if (result.status === 'error') hadCmdError = true;
const text = resultToText(result.result);
if (text) await deps.sendMessage(text);
});
// Advance cursor to the command — messages AFTER it remain pending for next poll.
deps.advanceCursor(cmdMsg.timestamp);
await deps.setTyping(false);
if (cmdOutput === 'error' || hadCmdError) {
await deps.sendMessage(`${command} failed. The session is unchanged.`);
}
return { handled: true, success: true };
}