Compare commits

...

32 Commits

Author SHA1 Message Date
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0dfde3aa5b Merge branch 'main' into cleanup/remove-onedb-shims 2026-07-04 19:37:36 +03:00
github-actions[bot] 33d2366252 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:35:58 +00:00
github-actions[bot] 9bf1514f26 chore: bump version to 2.1.34 2026-07-04 16:35:56 +00:00
gavrielc be3502c23b Merge pull request #2937 from nanocoai/cleanup/resolve-session-reprovision
Re-provision a missing session folder so the documented reset works
2026-07-04 19:35:44 +03:00
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc e3d2d43b0e Merge branch 'main' into cleanup/remove-dead-v1-config 2026-07-04 19:33:11 +03:00
github-actions[bot] afde23bbe6 chore: bump version to 2.1.32 2026-07-04 16:32:51 +00:00
gavrielc 776bc14ca0 Merge pull request #2934 from nanocoai/cleanup/reachable-perimeter-env
Make the security-perimeter env vars reachable under the shipped service
2026-07-04 19:32:37 +03:00
gavrielc a9461afef1 Merge branch 'main' into cleanup/reachable-perimeter-env 2026-07-04 19:32:28 +03:00
github-actions[bot] ca81558ec8 chore: bump version to 2.1.31 2026-07-04 16:30:56 +00:00
github-actions[bot] 2f88e4fa15 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:30:54 +00:00
gavrielc 9b0d1dd044 Merge pull request #2933 from nanocoai/feat/approval-button-styles
feat(approvals): colored buttons on approval cards (Slack primary/danger)
2026-07-04 19:30:42 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +03:00
gavrielc 0626dcec92 Delete one-DB-era @deprecated shims and dead exports
These are leftovers from the April-2026 inbound/outbound session-DB
split, marked @deprecated "kept temporarily for test compatibility":
sessionDbPath, openSessionDb, writeSystemResponse (session-manager),
getStuckProcessingIds (session-db, superseded by getProcessingClaims),
getSessionDb (container connection + barrel re-export), and the dead
registerResponseHandler/onShutdown re-exports from index.ts. The only
real callers are two dev scripts, repointed to inboundDbPath /
getInboundDb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:02 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
gavrielc d4ca8a4ea6 chore(config): remove dead v1 config knobs and broken pnpm auth script
These config exports in src/config.ts have zero readers and advertise
controls that v2 removed:

- CONTAINER_TIMEOUT / CONTAINER_MAX_OUTPUT_SIZE / IDLE_TIMEOUT /
  MAX_CONCURRENT_CONTAINERS / MAX_MESSAGES_PER_PROMPT
- the trigger block (escapeRegex, buildTriggerPattern, DEFAULT_TRIGGER,
  getTriggerPattern, TRIGGER_PATTERN)

Also remove the `auth` script from package.json — it pointed at the
deleted src/whatsapp-auth.ts, so `pnpm run auth` only errored — and fix
the stale MAX_MESSAGES_PER_PROMPT reference in the container's
messages-in.ts comment (the value comes from container.json's
maxMessagesPerPrompt).

Grep confirms no importers in src/, container/, scripts/, setup/, or
tests. Precedent: 0283391.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:34 +03:00
gavrielc 7615d7d846 Make security-perimeter env vars reachable under the shipped service
Egress-lockdown and CPU/memory limits were read from process.env only, but
the shipped launchd/systemd service sets just PATH+HOME and the host never
loads .env into process.env — so these knobs could not be turned on the
supported way. Route them through the same readEnvFile path as the other
config keys, keeping process.env precedence so dev-mode/nohup installs are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:27 +03:00
26 changed files with 458 additions and 202 deletions
+7 -9
View File
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
```
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
## Add Directories
Ask which directories the user wants agents to access. For each path:
- Validate the path exists
- Ask if it should be read-only for non-main agents (default: yes)
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
Build the JSON config and write it:
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
```
Use `--force` to overwrite the existing config.
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
```bash
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
```
## Reset to Empty
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automaticallyno service restart needed.
Run from your NanoClaw project root:
To apply the new config to a group that already has a running container, restart just that group:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
ncl groups restart --id <group-id>
```
@@ -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;
}
+5 -9
View File
@@ -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
View File
@@ -1,7 +1,6 @@
export {
getInboundDb,
getOutboundDb,
getSessionDb,
initTestSessionDb,
closeSessionDb,
touchHeartbeat,
+1 -2
View File
@@ -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();
+1 -1
View File
@@ -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';
+7 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.30",
"version": "2.1.35",
"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",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
<title>207k tokens, 104% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
<title>208k tokens, 104% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
<text x="71" y="14">208k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+4 -2
View File
@@ -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');
+14 -6
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6 -2
View File
@@ -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 || ''",
);
});
});
-8
View File
@@ -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;
+4 -6
View File
@@ -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
View File
@@ -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()) {
+119
View File
@@ -0,0 +1,119 @@
/**
* Tests for the mount allowlist loader/validator.
*
* Covers the two cleanups:
* - The loader honors the per-root `readOnly` key (translating it to
* `allowReadWrite`) and tolerates the top-level `nonMainReadOnly` key that
* setup writes into every fresh install.
* - The allowlist is read per call (mtime-keyed cache), so a parse error is
* never cached permanently a fixed file is picked up without a restart.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The config path is a module-level const in production; point it at a
// per-test temp file via a getter so each test is isolated from the cache.
const mockState = vi.hoisted(() => ({ allowlistPath: '' }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../config.js');
return {
...actual,
get MOUNT_ALLOWLIST_PATH() {
return mockState.allowlistPath;
},
};
});
import { loadMountAllowlist, validateMount } from './index.js';
let tmpDir: string;
let configFile: string;
let projectsDir: string;
let repoDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnt-sec-'));
configFile = path.join(tmpDir, 'mount-allowlist.json');
mockState.allowlistPath = configFile;
projectsDir = path.join(tmpDir, 'projects');
repoDir = path.join(projectsDir, 'repo');
fs.mkdirSync(repoDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeAllowlist(obj: unknown): void {
fs.writeFileSync(configFile, JSON.stringify(obj, null, 2) + '\n');
}
describe('loadMountAllowlist', () => {
it('translates per-root readOnly:false into a read-write grant', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, readOnly: false }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
// ...and a mount that requests read-write actually gets it.
const result = validateMount({ hostPath: repoDir, readonly: false });
expect(result.allowed).toBe(true);
expect(result.effectiveReadonly).toBe(false);
});
it('keeps readOnly:true as a read-only grant', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, readOnly: true }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(false);
const result = validateMount({ hostPath: repoDir, readonly: false });
expect(result.allowed).toBe(true);
expect(result.effectiveReadonly).toBe(true);
});
it('tolerates an unknown top-level nonMainReadOnly key', () => {
writeAllowlist({
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
blockedPatterns: [],
nonMainReadOnly: true,
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots).toHaveLength(1);
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
});
it('picks up a fixed file without a restart (parse errors are not cached)', () => {
// A broken edit blocks all mounts...
fs.writeFileSync(configFile, 'not valid json {');
expect(loadMountAllowlist()).toBeNull();
// ...but fixing the file recovers on the very next call — no restart.
writeAllowlist({
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
blockedPatterns: [],
});
const allowlist = loadMountAllowlist();
expect(allowlist).not.toBeNull();
expect(allowlist!.allowedRoots).toHaveLength(1);
});
it('returns null when the allowlist file is missing', () => {
// No file written.
expect(loadMountAllowlist()).toBeNull();
});
});
+81 -31
View File
@@ -29,9 +29,11 @@ export interface AllowedRoot {
description?: string;
}
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
let allowlistLoadError: string | null = null;
// Cache the last successfully-parsed allowlist, keyed on the file's path +
// mtime. A changed or fixed file is picked up on the next call (no restart),
// and a parse error is never cached permanently — one bad edit blocks mounts
// only until the file is fixed.
let cache: { path: string; mtimeMs: number; allowlist: MountAllowlist } | null = null;
/**
* Default blocked patterns - paths that should never be mounted
@@ -57,60 +59,108 @@ const DEFAULT_BLOCKED_PATTERNS = [
];
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Result is cached in memory for the lifetime of the process.
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
*
* The read-only decision is per-root. Historically this validator only read
* `allowReadWrite`, but the /manage-mounts skill and setup write `readOnly`
* instead so a `readOnly: false` grant was silently forced read-only.
* Translate `readOnly` `allowReadWrite = !readOnly` (with a warning) unless an
* explicit `allowReadWrite` is already present. With neither key, default to
* read-only (fail safe).
*/
export function loadMountAllowlist(): MountAllowlist | null {
if (cachedAllowlist !== null) {
return cachedAllowlist;
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
const rootPath = typeof root.path === 'string' ? root.path : '';
const description = typeof root.description === 'string' ? root.description : undefined;
let allowReadWrite: boolean;
if (typeof root.allowReadWrite === 'boolean') {
allowReadWrite = root.allowReadWrite;
} else if (typeof root.readOnly === 'boolean') {
allowReadWrite = !root.readOnly;
log.warn('Mount allowlist root uses "readOnly" — translating to allowReadWrite', {
root: rootPath,
readOnly: root.readOnly,
});
} else {
allowReadWrite = false;
}
if (allowlistLoadError !== null) {
// Already tried and failed, don't spam logs
return { path: rootPath, allowReadWrite, description };
}
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Re-reads on every call, but serves from an in-memory cache while the file's
* mtime is unchanged. A parse error is never cached fix the file and the next
* call recovers without a service restart.
*/
export function loadMountAllowlist(): MountAllowlist | null {
// Missing-file behavior: warn and block additional mounts, but do NOT cache
// the miss — the file may be created later without a restart.
let stat: fs.Stats;
try {
stat = fs.statSync(MOUNT_ALLOWLIST_PATH);
} catch {
log.warn(
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
{ path: MOUNT_ALLOWLIST_PATH },
);
return null;
}
try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
log.warn(
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
{ path: MOUNT_ALLOWLIST_PATH },
);
return null;
}
// Serve from cache only while the same file is unchanged since the last
// successful load. Any edit (including fixing a previously broken file) bumps
// the mtime and is picked up on the next call.
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
return cache.allowlist;
}
try {
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
const allowlist = JSON.parse(content) as MountAllowlist;
const raw = JSON.parse(content) as Record<string, unknown>;
// Validate structure
if (!Array.isArray(allowlist.allowedRoots)) {
if (!Array.isArray(raw.allowedRoots)) {
throw new Error('allowedRoots must be an array');
}
if (!Array.isArray(allowlist.blockedPatterns)) {
if (!Array.isArray(raw.blockedPatterns)) {
throw new Error('blockedPatterns must be an array');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
// every fresh install, but this validator has no concept of a "main" agent —
// read-only is decided per-root. Do NOT throw: a hard reject would fail
// closed and brick all mounts on a standard install.
if ('nonMainReadOnly' in raw) {
log.warn('Mount allowlist has unsupported top-level "nonMainReadOnly" key — ignoring (read-only is per-root)', {
path: MOUNT_ALLOWLIST_PATH,
});
}
cachedAllowlist = allowlist;
const allowedRoots = (raw.allowedRoots as Array<Record<string, unknown>>).map(normalizeRoot);
// Merge with default blocked patterns
const blockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...(raw.blockedPatterns as string[])])];
const allowlist: MountAllowlist = { allowedRoots, blockedPatterns };
cache = { path: MOUNT_ALLOWLIST_PATH, mtimeMs: stat.mtimeMs, allowlist };
log.info('Mount allowlist loaded successfully', {
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
});
return cachedAllowlist;
return allowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
// Do NOT poison the cache — a corrupt edit blocks mounts only until it's
// fixed, then the next call re-reads and recovers.
cache = null;
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
+79 -1
View File
@@ -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
View File
@@ -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.
*