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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
30
container/agent-runner/src/db/session-routing.ts
Normal file
30
container/agent-runner/src/db/session-routing.ts
Normal 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 };
|
||||
}
|
||||
@@ -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] }),
|
||||
});
|
||||
|
||||
|
||||
132
container/agent-runner/src/mcp-tools/credentials.ts
Normal file
132
container/agent-runner/src/mcp-tools/credentials.ts
Normal 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];
|
||||
@@ -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>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user