feat(v2): OneCLI 0.3.1 — approvals, credential collection, threaded routing

Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).

## OneCLI manual-approval handler

* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
  `configureManualApproval`; on each request, delivers an `ask_question` card
  to the admin agent group's first messaging group, persists a
  `pending_approvals` row, and waits on an in-memory Promise resolved by the
  admin's button click or an expiry timer. Expired cards are edited to
  "Expired (...)" and a startup sweep flushes any rows left over from a
  previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
  Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
  in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
  columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
  `platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
  nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
  through `resolveOneCLIApproval` before falling back to the
  session-bound approval path.

## Credential collection from chat

New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.

* `src/credentials.ts` — host-side handler: delivers the card to the
  conversation's own channel (not the admin channel — credential collection
  is a user-facing flow, distinct from admin approval), persists a
  `pending_credentials` row, drives the submit → `createSecret` → notify
  pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
  handles the `nccr:` action prefix by opening a Modal with a TextInput,
  registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
  tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
  helper to pick up the system message the host writes back.

## Threaded adapter routing

The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.

* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
  at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
  Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
  Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
  collapse to channel-level sessions). Threaded adapters override the
  wiring's `session_mode` to `'per-thread'` so each thread = a session
  (except `agent-shared`, which is preserved as a cross-channel intent the
  adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
  written by the host on every container wake from
  `session.messaging_group_id` + `session.thread_id`. Forward-compat
  `CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
  scheduling tools all default their routing (channel, platform, **and**
  thread) from the session when no explicit `to` is given. Explicit `to`
  uses the destination's channel with `thread_id = null` (cross-destination
  sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
  shortcut) now inherits `thread_id` from `RoutingContext` too — this was
  the root cause of Discord replies landing in the root channel even after
  `send_message` was wired correctly.

## Related cleanups

* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
  folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
  a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
  during the async buildContainerArgs / OneCLI `applyContainerConfig` window
  no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
  version assertion — it had to be bumped on every migration addition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-11 17:18:21 +03:00
parent 9dc8bc5d99
commit e92b245399
43 changed files with 1391 additions and 70 deletions

View File

@@ -7,7 +7,15 @@ export {
touchHeartbeat,
clearStaleProcessingAcks,
} from './connection.js';
export { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js';
export {
getPendingMessages,
markProcessing,
markCompleted,
markFailed,
getMessageIn,
findQuestionResponse,
findCredentialResponse,
} from './messages-in.js';
export type { MessageInRow } from './messages-in.js';
export { writeMessageOut, getUndeliveredMessages } from './messages-out.js';
export type { MessageOutRow, WriteMessageOut } from './messages-out.js';

View File

@@ -112,3 +112,20 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
return response;
}
/** Find a pending credential_response system message for a given credential id. */
export function findCredentialResponse(credentialId: string): MessageInRow | undefined {
const inbound = getInboundDb();
const outbound = getOutboundDb();
const response = inbound
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND kind = 'system' AND content LIKE ?")
.get(`%"credentialId":"${credentialId}"%`) as MessageInRow | undefined;
if (!response) return undefined;
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
if (acked) return undefined;
return response;
}

View File

@@ -0,0 +1,30 @@
/**
* Default reply routing for this session — written by the host on every
* container wake (see src/session-manager.ts `writeSessionRouting`).
*
* Read by the MCP tools as the default destination for outbound messages
* when the agent doesn't specify an explicit `to`. This is what makes
* "agent replies in the thread it's currently in" work: the router strips
* or preserves thread_id based on the adapter's thread support, and we
* just read the fixed routing the host committed for this session.
*/
import { getInboundDb } from './connection.js';
export interface SessionRouting {
channel_type: string | null;
platform_id: string | null;
thread_id: string | null;
}
export function getSessionRouting(): SessionRouting {
const db = getInboundDb();
try {
const row = db
.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1')
.get() as SessionRouting | undefined;
if (row) return row;
} catch {
// Table may not exist on an older session DB — fall through to defaults
}
return { channel_type: null, platform_id: null, thread_id: null };
}

View File

@@ -11,6 +11,7 @@ import path from 'path';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -37,14 +38,31 @@ function destinationList(): string {
/**
* Resolve a destination name to routing fields.
* If `to` is omitted and the agent has exactly one destination, that one is used.
* With multiple destinations, omitting `to` is an error.
*
* If `to` is omitted, use the session's default reply routing (channel +
* thread the conversation is in) — the agent replies in place.
*
* If `to` is specified, look up the named destination; thread_id is null
* because a cross-destination send starts a new conversation elsewhere.
*/
function resolveRouting(
to: string | undefined,
): { channel_type: string; platform_id: string; resolvedName: string } | { error: string } {
let name = to;
if (!name) {
):
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
| { error: string } {
if (!to) {
// Default: reply to whatever thread/channel this session is bound to.
const session = getSessionRouting();
if (session.channel_type && session.platform_id) {
return {
channel_type: session.channel_type,
platform_id: session.platform_id,
thread_id: session.thread_id,
resolvedName: '(current conversation)',
};
}
// No session routing (e.g., agent-shared or internal-only agent) —
// fall back to the legacy single-destination shortcut.
const all = getAllDestinations();
if (all.length === 0) return { error: 'No destinations configured.' };
if (all.length > 1) {
@@ -52,14 +70,19 @@ function resolveRouting(
error: `You have multiple destinations — specify "to". Options: ${all.map((d) => d.name).join(', ')}`,
};
}
name = all[0].name;
to = all[0].name;
}
const dest = findByName(name);
if (!dest) return { error: `Unknown destination "${name}". Known: ${destinationList()}` };
const dest = findByName(to);
if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
if (dest.type === 'channel') {
return { channel_type: dest.channelType!, platform_id: dest.platformId!, resolvedName: name };
return {
channel_type: dest.channelType!,
platform_id: dest.platformId!,
thread_id: null,
resolvedName: to,
};
}
return { channel_type: 'agent', platform_id: dest.agentGroupId!, resolvedName: name };
return { channel_type: 'agent', platform_id: dest.agentGroupId!, thread_id: null, resolvedName: to };
}
export const sendMessage: McpToolDefinition = {
@@ -89,7 +112,7 @@ export const sendMessage: McpToolDefinition = {
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: null,
thread_id: routing.thread_id,
content: JSON.stringify({ text }),
});
@@ -135,7 +158,7 @@ export const sendFile: McpToolDefinition = {
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: null,
thread_id: routing.thread_id,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});

View File

@@ -0,0 +1,132 @@
/**
* Credential collection MCP tool.
*
* trigger_credential_collection sends a card to the user and blocks until the
* host reports back whether the credential was saved, rejected, or failed.
* The credential value NEVER enters agent context — the user submits it into
* a modal whose value is consumed entirely on the host side, and the host
* only writes back a status string.
*/
import { findCredentialResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `cred-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const triggerCredentialCollection: McpToolDefinition = {
tool: {
name: 'trigger_credential_collection',
description:
'Collect a credential (API key, token, etc.) from the user for a third-party service. Research the service first so you can pass the correct host pattern, header name, and value format. A card is sent to the user with a button that opens a secure input modal — the value is inserted directly into OneCLI and never enters your context. Blocks until the user saves, rejects, or the request fails.',
inputSchema: {
type: 'object' as const,
properties: {
name: {
type: 'string',
description: 'Display name for the secret (e.g. "Resend API Key").',
},
type: {
type: 'string',
enum: ['generic', 'anthropic'],
description: "Secret type. Use 'generic' for most third-party APIs; 'anthropic' is reserved for Anthropic API keys.",
},
hostPattern: {
type: 'string',
description: 'Host pattern to match (e.g. "api.resend.com"). Used by OneCLI to know when to inject this credential.',
},
pathPattern: {
type: 'string',
description: 'Optional path pattern to match (e.g. "/v1/*").',
},
headerName: {
type: 'string',
description: 'Header name to inject the credential into (e.g. "Authorization"). Required for generic type.',
},
valueFormat: {
type: 'string',
description: 'Value format template. Use {value} as the placeholder. Example: "Bearer {value}". Defaults to "{value}".',
},
description: {
type: 'string',
description: 'User-facing explanation shown on the card and in the input modal.',
},
timeout: {
type: 'number',
description: 'Timeout in seconds (default: 600).',
},
},
required: ['name', 'hostPattern'],
},
},
async handler(args) {
const name = args.name as string;
const type = ((args.type as string) || 'generic') as 'generic' | 'anthropic';
const hostPattern = args.hostPattern as string;
const pathPattern = (args.pathPattern as string) || '';
const headerName = (args.headerName as string) || '';
const valueFormat = (args.valueFormat as string) || '';
const description = (args.description as string) || '';
const timeoutMs = ((args.timeout as number) || 600) * 1000;
if (!name || !hostPattern) return err('name and hostPattern are required');
const credentialId = generateId();
writeMessageOut({
id: credentialId,
kind: 'system',
content: JSON.stringify({
action: 'request_credential',
credentialId,
name,
type,
hostPattern,
pathPattern,
headerName,
valueFormat,
description,
}),
});
log(`trigger_credential_collection: ${credentialId}${name} (${hostPattern})`);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = findCredentialResponse(credentialId);
if (response) {
const parsed = JSON.parse(response.content) as {
status: 'saved' | 'rejected' | 'failed';
detail?: string;
};
markCompleted([response.id]);
log(`trigger_credential_collection result: ${credentialId}${parsed.status}`);
if (parsed.status === 'saved') return ok(parsed.detail || 'Credential saved.');
if (parsed.status === 'rejected') return err(parsed.detail || 'Credential request rejected.');
return err(parsed.detail || 'Credential request failed.');
}
await sleep(1000);
}
log(`trigger_credential_collection timeout: ${credentialId}`);
return err(`Credential request timed out after ${timeoutMs / 1000}s`);
},
};
export const credentialTools: McpToolDefinition[] = [triggerCredentialCollection];

View File

@@ -15,6 +15,7 @@ import { schedulingTools } from './scheduling.js';
import { interactiveTools } from './interactive.js';
import { agentTools } from './agents.js';
import { selfModTools } from './self-mod.js';
import { credentialTools } from './credentials.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
@@ -32,6 +33,7 @@ const allTools: McpToolDefinition[] = [
...interactiveTools,
...conditionalAgentTools,
...selfModTools,
...credentialTools,
];
const toolMap = new Map<string, McpToolDefinition>();

View File

@@ -6,6 +6,7 @@
*/
import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -17,11 +18,7 @@ function generateId(): string {
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
return getSessionRouting();
}
function ok(text: string) {

View File

@@ -7,6 +7,7 @@
*/
import { getInboundDb } from '../db/connection.js';
import { writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -18,11 +19,7 @@ function generateId(): string {
}
function routing() {
return {
platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
thread_id: process.env.NANOCLAW_THREAD_ID || null,
};
return getSessionRouting();
}
function ok(text: string) {

View File

@@ -387,13 +387,16 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
// Inherit thread_id from the inbound routing context so replies land in the
// same thread the conversation is in. For non-threaded adapters the router
// strips thread_id at ingest, so this will already be null.
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
thread_id: null,
thread_id: routing.threadId,
content: JSON.stringify({ text: body }),
});
}