mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| afa07f0566 | |||
| 8059ee4eec | |||
| 41c486cacc | |||
| 190a7d4f43 | |||
| e3d2d43b0e | |||
| afde23bbe6 | |||
| 776bc14ca0 | |||
| a9461afef1 | |||
| ca81558ec8 | |||
| 2f88e4fa15 | |||
| 9b0d1dd044 | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| b42329551d | |||
| 855f204d8a | |||
| d4ca8a4ea6 | |||
| 7615d7d846 |
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||
return {
|
||||
prepare: (sql: string) => db.prepare(sql),
|
||||
exec: (sql: string) => db.exec(sql),
|
||||
close: () => {},
|
||||
} as unknown as Database;
|
||||
}
|
||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getSessionDb(): Database {
|
||||
return getInboundDb();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getInboundDb,
|
||||
getOutboundDb,
|
||||
getSessionDb,
|
||||
initTestSessionDb,
|
||||
closeSessionDb,
|
||||
touchHeartbeat,
|
||||
|
||||
@@ -56,7 +56,7 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* Returns the most recent `maxMessagesPerPrompt` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
@@ -163,4 +163,3 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -2,8 +2,13 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.30",
|
||||
"version": "2.1.34",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -21,7 +21,6 @@
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"ncl": "tsx src/cli/client.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -33,7 +33,9 @@ db.exec(`
|
||||
`);
|
||||
|
||||
// Insert test message
|
||||
db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run(
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||
).run(
|
||||
'test-1',
|
||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||
);
|
||||
@@ -44,7 +46,7 @@ db.close();
|
||||
process.env.SESSION_DB_PATH = DB_PATH;
|
||||
process.env.AGENT_PROVIDER = 'claude';
|
||||
|
||||
const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getInboundDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
|
||||
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
|
||||
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
|
||||
|
||||
@@ -71,7 +71,7 @@ import { routeInbound } from '../src/router.js';
|
||||
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
|
||||
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
|
||||
import { findSession } from '../src/db/sessions.js';
|
||||
import { sessionDbPath } from '../src/session-manager.js';
|
||||
import { inboundDbPath } from '../src/session-manager.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
|
||||
|
||||
// Track delivered messages
|
||||
@@ -99,7 +99,9 @@ const mockAdapter: ChannelAdapter = {
|
||||
|
||||
async setTyping() {},
|
||||
async teardown() {},
|
||||
isConnected() { return true; },
|
||||
isConnected() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Register mock adapter
|
||||
@@ -179,15 +181,19 @@ console.log(`✓ Container status: ${session.container_status}`);
|
||||
import { execSync } from 'child_process';
|
||||
const checkContainerLogs = () => {
|
||||
try {
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"')
|
||||
.toString()
|
||||
.trim();
|
||||
for (const name of containers.split('\n').filter(Boolean)) {
|
||||
console.log(`\nContainer logs (${name}):`);
|
||||
console.log(execSync(`docker logs ${name} 2>&1`).toString());
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const sessDbPath = sessionDbPath('ag-chan', session.id);
|
||||
const sessDbPath = inboundDbPath('ag-chan', session.id);
|
||||
console.log(`✓ Session DB: ${sessDbPath}`);
|
||||
|
||||
// --- Step 4: Wait for delivery through mock adapter ---
|
||||
@@ -210,7 +216,9 @@ await new Promise<void>((resolve) => {
|
||||
console.log(` messages_out rows: ${out.length}`);
|
||||
if (out.length > 0) console.log(' (messages exist but delivery failed)');
|
||||
db.close();
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
checkContainerLogs();
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
|
||||
@@ -27,7 +27,7 @@ register({
|
||||
if (cliScope === 'group') {
|
||||
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
|
||||
}
|
||||
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||
const commands = listCommands().filter((c) => !c.resource);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
+1
-2
@@ -10,14 +10,13 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { register } from './registry.js';
|
||||
import type { Access } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
export interface ColumnDef {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'json';
|
||||
|
||||
+1
-3
@@ -11,7 +11,7 @@
|
||||
export type RequestFrame = {
|
||||
/** Correlation key set by the client. */
|
||||
id: string;
|
||||
/** Registry name, e.g. "list-groups". */
|
||||
/** Registry name, e.g. "groups-list". */
|
||||
command: string;
|
||||
/** Command-specific. Each command's parseArgs validates. */
|
||||
args: Record<string, unknown>;
|
||||
@@ -24,10 +24,8 @@ export type ResponseFrame =
|
||||
export type ErrorCode =
|
||||
| 'unknown-command'
|
||||
| 'invalid-args'
|
||||
| 'permission-denied'
|
||||
| 'forbidden'
|
||||
| 'approval-pending'
|
||||
| 'not-found'
|
||||
| 'handler-error'
|
||||
| 'transport-error';
|
||||
|
||||
|
||||
+14
-5
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* Command registry — single source of truth for what `ncl` can do.
|
||||
*
|
||||
* Each command file under `commands/` calls `register()` at top level,
|
||||
* and `commands/index.ts` imports them all for side effects so the
|
||||
* registry is populated before the host's CLI server accepts connections.
|
||||
* Most commands come from resource modules under `resources/`, which call
|
||||
* `registerResource()` (one `register()` per CRUD verb); the top-level `help`
|
||||
* command and the per-resource help commands register directly. The barrel
|
||||
* `commands/index.ts` imports the resource barrel for its side effects and then
|
||||
* registers the help commands, so the registry is populated before the host's
|
||||
* CLI server accepts connections.
|
||||
*/
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
export type Access = 'open' | 'approval';
|
||||
|
||||
export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
access: Access;
|
||||
/** Resource this command belongs to (for help grouping). */
|
||||
/**
|
||||
* The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher
|
||||
* only lets an agent run commands whose `resource` is on the whitelist
|
||||
* (`groups`, `sessions`, `destinations`, `members`); it also drives help
|
||||
* grouping. Omitting `resource` exempts the command from the whitelist —
|
||||
* that's how general commands like `help` stay reachable in group scope.
|
||||
*/
|
||||
resource?: string;
|
||||
/**
|
||||
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
|
||||
|
||||
+21
-24
@@ -6,7 +6,18 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
|
||||
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', 'ONECLI_API_KEY', 'TZ']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'ONECLI_API_KEY',
|
||||
'TZ',
|
||||
'CONTAINER_CPU_LIMIT',
|
||||
'CONTAINER_MEMORY_LIMIT',
|
||||
'NANOCLAW_EGRESS_LOCKDOWN',
|
||||
'NANOCLAW_EGRESS_NETWORK',
|
||||
'ONECLI_GATEWAY_CONTAINER',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
@@ -37,35 +48,21 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContaine
|
||||
// cleanupOrphans only reaps containers from this install, not peers.
|
||||
export const INSTALL_SLUG = getInstallSlug(PROJECT_ROOT);
|
||||
export const CONTAINER_INSTALL_LABEL = `nanoclaw-install=${INSTALL_SLUG}`;
|
||||
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 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;
|
||||
export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API_KEY;
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps, passed through to `docker run`. Default empty =
|
||||
// no flag added = today's unbounded behavior (don't OOM existing OSS workloads).
|
||||
// Operators opt in: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || envConfig.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || envConfig.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildTriggerPattern(trigger: string): RegExp {
|
||||
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
|
||||
}
|
||||
|
||||
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
|
||||
|
||||
export function getTriggerPattern(trigger?: string): RegExp {
|
||||
const normalizedTrigger = trigger?.trim();
|
||||
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
|
||||
// Egress lockdown — force all agent traffic through the OneCLI gateway on a
|
||||
// no-internet Docker network. Off by default; consumed by src/egress-lockdown.ts.
|
||||
export const EGRESS_LOCKDOWN = (process.env.NANOCLAW_EGRESS_LOCKDOWN || envConfig.NANOCLAW_EGRESS_LOCKDOWN) === 'true';
|
||||
export const EGRESS_NETWORK =
|
||||
process.env.NANOCLAW_EGRESS_NETWORK || envConfig.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
|
||||
export const ONECLI_GATEWAY_CONTAINER =
|
||||
process.env.ONECLI_GATEWAY_CONTAINER || envConfig.ONECLI_GATEWAY_CONTAINER || 'onecli';
|
||||
|
||||
// Timezone for scheduled tasks, message formatting, etc.
|
||||
// Validates each candidate is a real IANA identifier before accepting.
|
||||
|
||||
@@ -73,8 +73,12 @@ describe('per-container resource limits (structural)', () => {
|
||||
|
||||
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
|
||||
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
|
||||
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
|
||||
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
|
||||
expect(cfg).toContain(
|
||||
"CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || envConfig.CONTAINER_CPU_LIMIT || ''",
|
||||
);
|
||||
expect(cfg).toContain(
|
||||
"CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || envConfig.CONTAINER_MEMORY_LIMIT || ''",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -181,14 +181,6 @@ export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Data
|
||||
})();
|
||||
}
|
||||
|
||||
export function getStuckProcessingIds(outDb: Database.Database): string[] {
|
||||
return (
|
||||
outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
|
||||
message_id: string;
|
||||
}>
|
||||
).map((r) => r.message_id);
|
||||
}
|
||||
|
||||
export interface ProcessingClaim {
|
||||
message_id: string;
|
||||
status_changed: string;
|
||||
|
||||
@@ -9,15 +9,13 @@
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { EGRESS_LOCKDOWN, EGRESS_NETWORK, ONECLI_GATEWAY_CONTAINER } from './config.js';
|
||||
import { CONTAINER_RUNTIME_BIN } from './container-runtime.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
/** Locked-down, no-internet network agents are placed on. */
|
||||
export const EGRESS_NETWORK = process.env.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
|
||||
/** The OneCLI gateway container attached as the only egress hop. */
|
||||
const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || 'onecli';
|
||||
/** Off by default; set NANOCLAW_EGRESS_LOCKDOWN=true to opt in. */
|
||||
const EGRESS_LOCKDOWN = process.env.NANOCLAW_EGRESS_LOCKDOWN === 'true';
|
||||
// Perimeter knobs (locked-down network, gateway container, on/off flag) are read
|
||||
// via config.ts so they honor .env under the shipped service, not just process.env.
|
||||
export { EGRESS_NETWORK };
|
||||
|
||||
/** Raised when lockdown is requested but can't be established. */
|
||||
export class EgressLockdownError extends Error {
|
||||
|
||||
+1
-10
@@ -24,16 +24,7 @@ import { enforceUpgradeTripwire } from './upgrade-state.js';
|
||||
// effects, and the modules call registerResponseHandler/onShutdown at top
|
||||
// level — which would hit a TDZ error if the arrays lived here. Re-exported
|
||||
// here so existing callers see the same surface.
|
||||
import {
|
||||
registerResponseHandler,
|
||||
getResponseHandlers,
|
||||
onShutdown,
|
||||
getShutdownCallbacks,
|
||||
type ResponsePayload,
|
||||
type ResponseHandler,
|
||||
} from './response-registry.js';
|
||||
export { registerResponseHandler, onShutdown };
|
||||
export type { ResponsePayload, ResponseHandler };
|
||||
import { getResponseHandlers, getShutdownCallbacks, type ResponsePayload } from './response-registry.js';
|
||||
|
||||
async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
||||
for (const handler of getResponseHandlers()) {
|
||||
|
||||
@@ -16,7 +16,17 @@ vi.mock('./config.js', async () => {
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
|
||||
});
|
||||
|
||||
import { initSessionFolder, outboundDbPath, writeOutboundDirect } from './session-manager.js';
|
||||
import {
|
||||
initSessionFolder,
|
||||
inboundDbPath,
|
||||
outboundDbPath,
|
||||
sessionDir,
|
||||
writeOutboundDirect,
|
||||
writeSessionMessage,
|
||||
} from './session-manager.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
|
||||
import { createSession } from './db/sessions.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
|
||||
const AG = 'ag-test';
|
||||
@@ -98,3 +108,71 @@ describe('writeOutboundDirect', () => {
|
||||
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The `/debug` skill tells operators to `rm -rf` a session folder to reset a
|
||||
* stuck session. The sessions row survives, so the next message takes the
|
||||
* existing-session path and lands in `writeSessionMessage` with a missing
|
||||
* inbound.db. Without re-provisioning, better-sqlite3 throws on open and the
|
||||
* message is logged-and-dropped forever — the reset silently kills the chat.
|
||||
*/
|
||||
describe('writeSessionMessage re-provisions a deleted session folder', () => {
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createAgentGroup({
|
||||
id: AG,
|
||||
name: 'Reset',
|
||||
folder: 'reset',
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
const sess: Session = {
|
||||
id: SESS,
|
||||
agent_group_id: AG,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
createSession(sess);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
it('re-creates the folder + inbound.db and does not throw when the row still exists', () => {
|
||||
// Operator resets a stuck session by deleting its folder; the row survives.
|
||||
fs.rmSync(sessionDir(AG, SESS), { recursive: true, force: true });
|
||||
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(false);
|
||||
|
||||
expect(() =>
|
||||
writeSessionMessage(AG, SESS, {
|
||||
id: 'after-reset-1',
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: 'slack:C1',
|
||||
channelType: 'slack',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text: 'still here?' }),
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
// The folder + inbound.db are back and the message landed.
|
||||
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(true);
|
||||
const db = new Database(inboundDbPath(AG, SESS), { readonly: true });
|
||||
try {
|
||||
const row = db.prepare('SELECT id, content FROM messages_in WHERE id = ?').get('after-reset-1') as
|
||||
| { id: string; content: string }
|
||||
| undefined;
|
||||
expect(row?.id).toBe('after-reset-1');
|
||||
expect(JSON.parse(row!.content).text).toBe('still here?');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+10
-36
@@ -64,14 +64,6 @@ export function heartbeatPath(agentGroupId: string, sessionId: string): string {
|
||||
return path.join(sessionDir(agentGroupId, sessionId), '.heartbeat');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use inboundDbPath / outboundDbPath instead.
|
||||
* Kept temporarily for test compatibility during migration.
|
||||
*/
|
||||
export function sessionDbPath(agentGroupId: string, sessionId: string): string {
|
||||
return inboundDbPath(agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -219,6 +211,16 @@ export function writeSessionMessage(
|
||||
onWake?: 0 | 1;
|
||||
},
|
||||
): void {
|
||||
// Documented reset: operators `rm -rf` a session folder to clear a stuck
|
||||
// session. The sessions row survives, so the next message takes the
|
||||
// existing-session path and lands here with a missing inbound.db — the open
|
||||
// below would throw and the message would be logged-and-dropped forever.
|
||||
// Re-provision the folder + DBs (initSessionFolder is idempotent) so the
|
||||
// documented reset actually re-provisions instead of killing the chat.
|
||||
if (!fs.existsSync(inboundDbPath(agentGroupId, sessionId))) {
|
||||
initSessionFolder(agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
// Extract base64 attachment data, save to inbox, replace with file paths
|
||||
const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
|
||||
|
||||
@@ -392,34 +394,6 @@ export function writeOutboundDirect(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use openInboundDb / openOutboundDb instead.
|
||||
*/
|
||||
export function openSessionDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
return openInboundDb(agentGroupId, sessionId);
|
||||
}
|
||||
|
||||
/** Write a system response to a session's inbound.db so the container's findQuestionResponse() picks it up. */
|
||||
export function writeSystemResponse(
|
||||
agentGroupId: string,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
status: string,
|
||||
result: Record<string, unknown>,
|
||||
): void {
|
||||
writeSessionMessage(agentGroupId, sessionId, {
|
||||
id: `sys-resp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: JSON.stringify({
|
||||
type: 'question_response',
|
||||
questionId: requestId,
|
||||
status,
|
||||
result,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load outbox attachments for a delivered message.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user