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>
313 lines
9.7 KiB
TypeScript
313 lines
9.7 KiB
TypeScript
/**
|
|
* Credential collection flow.
|
|
*
|
|
* Agent calls `trigger_credential_collection` — container writes a system
|
|
* action `request_credential` into outbound.db. This module:
|
|
*
|
|
* 1. Delivers an `[Enter credential] [Reject]` card to the admin channel.
|
|
* 2. On "Enter credential" click, the Chat SDK bridge opens a modal with a
|
|
* TextInput, captures the user's value in `onModalSubmit`, and calls
|
|
* `handleCredentialSubmit()` here.
|
|
* 3. We insert the secret into OneCLI and write a system chat message into
|
|
* the agent's session DB so the blocking MCP tool call returns.
|
|
* 4. The credential value never enters any session DB or log line.
|
|
*/
|
|
import {
|
|
createPendingCredential,
|
|
deletePendingCredential,
|
|
getPendingCredential as getPendingCredentialRow,
|
|
updatePendingCredentialMessageId,
|
|
updatePendingCredentialStatus,
|
|
} from './db/credentials.js';
|
|
import { getMessagingGroup } from './db/messaging-groups.js';
|
|
import type { ChannelDeliveryAdapter } from './delivery.js';
|
|
import { log } from './log.js';
|
|
import { createSecret, OneCLISecretError } from './onecli-secrets.js';
|
|
import { writeSessionMessage } from './session-manager.js';
|
|
import type { PendingCredential, Session } from './types.js';
|
|
import { wakeContainer } from './container-runner.js';
|
|
|
|
let adapterRef: ChannelDeliveryAdapter | null = null;
|
|
|
|
export function setCredentialDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
|
|
adapterRef = adapter;
|
|
}
|
|
|
|
/** Handle a `request_credential` system action from a container. */
|
|
export async function handleCredentialRequest(
|
|
content: Record<string, unknown>,
|
|
session: Session,
|
|
): Promise<void> {
|
|
if (!adapterRef) {
|
|
notifyAgentCredentialResult(session, content.credentialId as string, 'failed', 'delivery adapter not ready');
|
|
return;
|
|
}
|
|
|
|
const credentialId = (content.credentialId as string) || '';
|
|
const name = (content.name as string) || '';
|
|
const type = ((content.type as string) || 'generic') as 'generic' | 'anthropic';
|
|
const hostPattern = (content.hostPattern as string) || '';
|
|
const pathPattern = (content.pathPattern as string) || null;
|
|
const headerName = (content.headerName as string) || null;
|
|
const valueFormat = (content.valueFormat as string) || null;
|
|
const description = (content.description as string) || null;
|
|
|
|
if (!credentialId || !name || !hostPattern) {
|
|
notifyAgentCredentialResult(
|
|
session,
|
|
credentialId,
|
|
'failed',
|
|
'name and hostPattern are required',
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Deliver the credential card to the channel where the conversation is
|
|
// happening — not the admin channel. The user triggered this request by
|
|
// chatting with the agent, so the response surface is their chat channel.
|
|
if (!session.messaging_group_id) {
|
|
notifyAgentCredentialResult(
|
|
session,
|
|
credentialId,
|
|
'failed',
|
|
'session has no messaging group — cannot deliver credential card',
|
|
);
|
|
return;
|
|
}
|
|
const mg = getMessagingGroup(session.messaging_group_id);
|
|
if (!mg) {
|
|
notifyAgentCredentialResult(session, credentialId, 'failed', 'messaging group not found');
|
|
return;
|
|
}
|
|
|
|
createPendingCredential({
|
|
id: credentialId,
|
|
agent_group_id: session.agent_group_id,
|
|
session_id: session.id,
|
|
name,
|
|
type,
|
|
host_pattern: hostPattern,
|
|
path_pattern: pathPattern,
|
|
header_name: headerName,
|
|
value_format: valueFormat,
|
|
description,
|
|
channel_type: mg.channel_type,
|
|
platform_id: mg.platform_id,
|
|
platform_message_id: null,
|
|
status: 'pending',
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
|
|
const question = buildCardText({
|
|
name,
|
|
hostPattern,
|
|
headerName,
|
|
valueFormat,
|
|
description,
|
|
});
|
|
|
|
let platformMessageId: string | undefined;
|
|
try {
|
|
platformMessageId = await adapterRef.deliver(
|
|
mg.channel_type,
|
|
mg.platform_id,
|
|
session.thread_id,
|
|
'chat-sdk',
|
|
JSON.stringify({
|
|
type: 'credential_request',
|
|
credentialId,
|
|
question,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
log.error('Failed to deliver credential request card', { credentialId, err });
|
|
updatePendingCredentialStatus(credentialId, 'failed');
|
|
notifyAgentCredentialResult(session, credentialId, 'failed', 'could not deliver card');
|
|
return;
|
|
}
|
|
|
|
if (platformMessageId) {
|
|
updatePendingCredentialMessageId(credentialId, platformMessageId);
|
|
}
|
|
|
|
log.info('Credential request delivered', { credentialId, name, hostPattern });
|
|
}
|
|
|
|
/** Called by chat-sdk-bridge to fetch metadata for building the modal. */
|
|
export function getCredentialForModal(
|
|
credentialId: string,
|
|
): { name: string; description: string | null; hostPattern: string } | null {
|
|
const row = getPendingCredentialRow(credentialId);
|
|
if (!row || row.status !== 'pending') return null;
|
|
return { name: row.name, description: row.description, hostPattern: row.host_pattern };
|
|
}
|
|
|
|
/** Admin clicked "Reject" on the card (or cancelled the modal). */
|
|
export async function handleCredentialReject(credentialId: string): Promise<void> {
|
|
const row = getPendingCredentialRow(credentialId);
|
|
if (!row) return;
|
|
updatePendingCredentialStatus(credentialId, 'rejected');
|
|
|
|
if (row.session_id) {
|
|
await notifyAgentSessionResult(
|
|
row.agent_group_id,
|
|
row.session_id,
|
|
credentialId,
|
|
'rejected',
|
|
`Credential request for ${row.name} was rejected by admin.`,
|
|
);
|
|
}
|
|
|
|
deletePendingCredential(credentialId);
|
|
log.info('Credential request rejected', { credentialId });
|
|
}
|
|
|
|
/**
|
|
* Admin submitted the modal with a credential value.
|
|
* The value is held only long enough to call OneCLI and is then dropped.
|
|
*/
|
|
export async function handleCredentialSubmit(credentialId: string, value: string): Promise<void> {
|
|
const row = getPendingCredentialRow(credentialId);
|
|
if (!row) {
|
|
log.warn('Credential submit for unknown id', { credentialId });
|
|
return;
|
|
}
|
|
if (row.status !== 'pending') {
|
|
log.warn('Credential submit for non-pending row', { credentialId, status: row.status });
|
|
return;
|
|
}
|
|
|
|
updatePendingCredentialStatus(credentialId, 'submitted');
|
|
|
|
try {
|
|
await createSecret({
|
|
name: row.name,
|
|
type: row.type,
|
|
value,
|
|
hostPattern: row.host_pattern,
|
|
pathPattern: row.path_pattern ?? undefined,
|
|
headerName: row.header_name ?? undefined,
|
|
valueFormat: row.value_format ?? undefined,
|
|
agentId: row.agent_group_id, // honored once OneCLI SDK adds scoping
|
|
});
|
|
} catch (err) {
|
|
const reason = err instanceof OneCLISecretError ? err.message : String(err);
|
|
log.error('Failed to create OneCLI secret', { credentialId, reason });
|
|
updatePendingCredentialStatus(credentialId, 'failed');
|
|
if (row.session_id) {
|
|
await notifyAgentSessionResult(
|
|
row.agent_group_id,
|
|
row.session_id,
|
|
credentialId,
|
|
'failed',
|
|
`Credential save failed: ${reason}`,
|
|
);
|
|
}
|
|
deletePendingCredential(credentialId);
|
|
return;
|
|
}
|
|
|
|
updatePendingCredentialStatus(credentialId, 'saved');
|
|
log.info('Credential saved', { credentialId, name: row.name, hostPattern: row.host_pattern });
|
|
|
|
if (row.session_id) {
|
|
await notifyAgentSessionResult(
|
|
row.agent_group_id,
|
|
row.session_id,
|
|
credentialId,
|
|
'saved',
|
|
`Credential "${row.name}" saved (host pattern: ${row.host_pattern}).`,
|
|
);
|
|
}
|
|
|
|
deletePendingCredential(credentialId);
|
|
}
|
|
|
|
/**
|
|
* Fallback for inbound channels that don't support modals — the bridge calls
|
|
* this when `event.openModal()` is unavailable or returned undefined.
|
|
*/
|
|
export async function handleCredentialChannelUnsupported(credentialId: string): Promise<void> {
|
|
const row = getPendingCredentialRow(credentialId);
|
|
if (!row) return;
|
|
updatePendingCredentialStatus(credentialId, 'failed');
|
|
if (row.session_id) {
|
|
await notifyAgentSessionResult(
|
|
row.agent_group_id,
|
|
row.session_id,
|
|
credentialId,
|
|
'failed',
|
|
`This channel doesn't support credential collection modals. Use Slack, Discord, Teams, or Google Chat.`,
|
|
);
|
|
}
|
|
deletePendingCredential(credentialId);
|
|
}
|
|
|
|
function notifyAgentCredentialResult(
|
|
session: Session,
|
|
credentialId: string,
|
|
status: 'saved' | 'rejected' | 'failed',
|
|
detail: string,
|
|
): void {
|
|
writeSessionMessage(session.agent_group_id, session.id, {
|
|
id: `cred-${credentialId}-${Date.now()}`,
|
|
kind: 'system',
|
|
timestamp: new Date().toISOString(),
|
|
platformId: session.agent_group_id,
|
|
channelType: 'agent',
|
|
threadId: null,
|
|
content: JSON.stringify({
|
|
type: 'credential_response',
|
|
credentialId,
|
|
status,
|
|
detail,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function notifyAgentSessionResult(
|
|
agentGroupId: string,
|
|
sessionId: string,
|
|
credentialId: string,
|
|
status: 'saved' | 'rejected' | 'failed',
|
|
detail: string,
|
|
): Promise<void> {
|
|
writeSessionMessage(agentGroupId, sessionId, {
|
|
id: `cred-${credentialId}-${Date.now()}`,
|
|
kind: 'system',
|
|
timestamp: new Date().toISOString(),
|
|
platformId: agentGroupId,
|
|
channelType: 'agent',
|
|
threadId: null,
|
|
content: JSON.stringify({
|
|
type: 'credential_response',
|
|
credentialId,
|
|
status,
|
|
detail,
|
|
}),
|
|
});
|
|
|
|
const { getSession } = await import('./db/sessions.js');
|
|
const session = getSession(sessionId);
|
|
if (session) await wakeContainer(session);
|
|
}
|
|
|
|
function buildCardText(opts: {
|
|
name: string;
|
|
hostPattern: string;
|
|
headerName: string | null;
|
|
valueFormat: string | null;
|
|
description: string | null;
|
|
}): string {
|
|
const lines = [
|
|
`🔑 Credential request: ${opts.name}`,
|
|
'',
|
|
`Host: \`${opts.hostPattern}\``,
|
|
];
|
|
if (opts.headerName) lines.push(`Header: \`${opts.headerName}\``);
|
|
if (opts.valueFormat) lines.push(`Format: \`${opts.valueFormat}\``);
|
|
if (opts.description) lines.push('', opts.description);
|
|
lines.push('', 'Click Enter credential to provide the value, or Reject to decline.');
|
|
return lines.join('\n');
|
|
}
|