refactor(v2): remove trigger_credential_collection MCP tool

Drops the in-chat credential-collection flow introduced in e92b245. Agents
can no longer collect API keys via a secure modal — users must add secrets
through OneCLI directly. Keeps the OneCLI manual-approval handler and
threaded-routing work from the same commit intact.

Removed:
* container/agent-runner/src/mcp-tools/credentials.ts (MCP tool)
* src/credentials.ts (host-side modal/OneCLI pipeline)
* src/db/credentials.ts + migration 005 (pending_credentials table)
* src/onecli-secrets.ts (createSecret CLI facade, only caller was credentials.ts)
* findCredentialResponse from agent-runner DB layer
* PendingCredential types
* Four credential hooks from ChannelSetup (getCredentialForModal,
  onCredentialReject, onCredentialSubmit, onCredentialChannelUnsupported)
* Credential card/modal handling in chat-sdk-bridge (nccr/nccm prefixes,
  Modal/TextInput imports)
* credential_request text fallback in WhatsApp adapter
* request_credential system-action case in delivery.ts

Added:
* Migration 009 drops pending_credentials on existing installs.

Vercel skill now tells the agent to ask the user to register the token via
OneCLI instead of invoking the removed tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-16 21:41:41 +03:00
parent e55ed0f4e8
commit cc784ff94b
23 changed files with 29 additions and 823 deletions

View File

@@ -27,14 +27,6 @@ export interface ChannelSetup {
/** Called when a user clicks a button/action in a card (e.g., ask_user_question response). */
onAction(questionId: string, selectedOption: string, userId: string): void;
/** Credential collection hooks — used by chat-sdk-bridge to route the modal flow. */
getCredentialForModal?(
credentialId: string,
): { name: string; description: string | null; hostPattern: string } | null;
onCredentialReject?(credentialId: string): void;
onCredentialSubmit?(credentialId: string, value: string): void;
onCredentialChannelUnsupported?(credentialId: string): void;
}
/** Inbound message from adapter to host. */

View File

@@ -12,8 +12,6 @@ import {
CardText,
Actions,
Button,
Modal,
TextInput,
type Adapter,
type ConcurrencyStrategy,
type Message as ChatMessage,
@@ -191,72 +189,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
await thread.subscribe();
});
// Handle button clicks (ask_user_question, credential card)
// Handle button clicks (ask_user_question)
chat.onAction(async (event) => {
// Credential card actions: nccr:<credentialId>:<enter|reject>
if (event.actionId.startsWith('nccr:')) {
const [, credentialId, subAction] = event.actionId.split(':');
if (!credentialId || !subAction) return;
if (subAction === 'reject') {
try {
await adapter.editMessage(event.threadId, event.messageId, {
markdown: `🔑 Credential request\n\n❌ Rejected`,
});
} catch (err) {
log.warn('Failed to update credential card after reject', { err });
}
setupConfig.onCredentialReject?.(credentialId);
return;
}
if (subAction === 'enter') {
const pending = setupConfig.getCredentialForModal?.(credentialId);
if (!pending) {
log.warn('Credential card clicked but row not pending', { credentialId });
return;
}
try {
const modalChildren = [
CardText(pending.description ?? `Enter the value for ${pending.name} (host: ${pending.hostPattern}).`),
TextInput({
id: 'value',
label: pending.name,
placeholder: 'Paste your credential value',
}),
];
// Modal children include a text element for context; the SDK
// accepts TextElement in ModalChild so this is valid.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modal = Modal({
callbackId: `nccm:${credentialId}`,
title: 'Enter credential',
submitLabel: 'Save',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: modalChildren as any,
});
const result = await event.openModal(modal);
if (!result) {
log.warn('openModal returned undefined — channel unsupported', { credentialId });
setupConfig.onCredentialChannelUnsupported?.(credentialId);
try {
await adapter.editMessage(event.threadId, event.messageId, {
markdown: `🔑 Credential request\n\n⚠ This channel does not support modals.`,
});
} catch {
// best effort
}
}
} catch (err) {
log.error('Failed to open credential modal', { credentialId, err });
setupConfig.onCredentialChannelUnsupported?.(credentialId);
}
return;
}
return;
}
if (!event.actionId.startsWith('ncq:')) return;
const parts = event.actionId.split(':');
if (parts.length < 3) return;
@@ -283,18 +217,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
setupConfig.onAction(questionId, selectedOption, userId);
});
// Modal submissions for credential collection
chat.onModalSubmit(async (event) => {
if (!event.callbackId.startsWith('nccm:')) return;
const credentialId = event.callbackId.slice('nccm:'.length);
const value = event.values?.value ?? '';
if (!value) {
log.warn('Credential modal submitted with empty value', { credentialId });
return;
}
setupConfig.onCredentialSubmit?.(credentialId, value);
});
await chat.initialize();
// Start Gateway listener for adapters that support it (e.g., Discord)
@@ -394,26 +316,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
return result?.id;
}
// Credential request card — buttons open a modal for secure input
if (content.type === 'credential_request' && content.credentialId) {
const credentialId = content.credentialId as string;
const card = Card({
title: '🔑 Credential request',
children: [
CardText(content.question as string),
Actions([
Button({ id: `nccr:${credentialId}:enter`, label: 'Enter credential', value: 'enter' }),
Button({ id: `nccr:${credentialId}:reject`, label: 'Reject', value: 'reject' }),
]),
],
});
const result = await adapter.postMessage(tid, {
card,
fallbackText: `Credential request — open in a channel that supports modals.`,
});
return result?.id;
}
// Normal message
const rawText = (content.markdown as string) || (content.text as string);
const text = rawText ? transformText(rawText) : rawText;

View File

@@ -664,14 +664,6 @@ registerChannelAdapter('whatsapp', {
return;
}
// Credential request → text fallback (WhatsApp doesn't support modals)
if (content.type === 'credential_request' && content.credentialId) {
const question = (content.question as string) || 'A credential has been requested.';
const text = `Credential request: ${question}\n\nPlease provide this credential through a secure channel (e.g. Discord or Slack).`;
const prefixed = ASSISTANT_HAS_OWN_NUMBER ? text : `${ASSISTANT_NAME}: ${text}`;
return sendRawMessage(platformId, prefixed);
}
// Normal message (with optional file attachments)
const text = (content.markdown as string) || (content.text as string);
const hasFiles = message.files && message.files.length > 0;

View File

@@ -1,300 +0,0 @@
/**
* 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');
}

View File

@@ -1,33 +0,0 @@
import type { PendingCredential, PendingCredentialStatus } from '../types.js';
import { getDb } from './connection.js';
export function createPendingCredential(c: PendingCredential): void {
getDb()
.prepare(
`INSERT INTO pending_credentials
(id, agent_group_id, session_id, name, type, host_pattern, path_pattern,
header_name, value_format, description, channel_type, platform_id,
platform_message_id, status, created_at)
VALUES
(@id, @agent_group_id, @session_id, @name, @type, @host_pattern, @path_pattern,
@header_name, @value_format, @description, @channel_type, @platform_id,
@platform_message_id, @status, @created_at)`,
)
.run(c);
}
export function getPendingCredential(id: string): PendingCredential | undefined {
return getDb().prepare('SELECT * FROM pending_credentials WHERE id = ?').get(id) as PendingCredential | undefined;
}
export function updatePendingCredentialStatus(id: string, status: PendingCredentialStatus): void {
getDb().prepare('UPDATE pending_credentials SET status = ? WHERE id = ?').run(status, id);
}
export function updatePendingCredentialMessageId(id: string, platformMessageId: string): void {
getDb().prepare('UPDATE pending_credentials SET platform_message_id = ? WHERE id = ?').run(platformMessageId, id);
}
export function deletePendingCredential(id: string): void {
getDb().prepare('DELETE FROM pending_credentials WHERE id = ?').run(id);
}

View File

@@ -58,10 +58,3 @@ export {
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
createPendingCredential,
getPendingCredential,
updatePendingCredentialStatus,
updatePendingCredentialMessageId,
deletePendingCredential,
} from './credentials.js';

View File

@@ -1,34 +0,0 @@
import type { Migration } from './index.js';
/**
* `pending_credentials` — backs the trigger_credential_collection flow.
* One row per in-flight credential request; status transitions
* pending → submitted → saved | rejected | failed.
*/
export const migration005: Migration = {
version: 5,
name: 'pending-credentials',
up(db) {
db.exec(`
CREATE TABLE pending_credentials (
id TEXT PRIMARY KEY,
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
session_id TEXT REFERENCES sessions(id),
name TEXT NOT NULL,
type TEXT NOT NULL,
host_pattern TEXT NOT NULL,
path_pattern TEXT,
header_name TEXT,
value_format TEXT,
description TEXT,
channel_type TEXT NOT NULL,
platform_id TEXT NOT NULL,
platform_message_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL
);
CREATE INDEX idx_pending_credentials_status ON pending_credentials(status);
`);
},
};

View File

@@ -0,0 +1,13 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration009: Migration = {
version: 9,
name: 'drop-pending-credentials',
up: (db: Database.Database) => {
db.exec(`
DROP INDEX IF EXISTS idx_pending_credentials_status;
DROP TABLE IF EXISTS pending_credentials;
`);
},
};

View File

@@ -5,9 +5,9 @@ import { migration001 } from './001-initial.js';
import { migration002 } from './002-chat-sdk-state.js';
import { migration003 } from './003-pending-approvals.js';
import { migration004 } from './004-agent-destinations.js';
import { migration005 } from './005-pending-credentials.js';
import { migration007 } from './007-pending-approvals-title-options.js';
import { migration008 } from './008-dropped-messages.js';
import { migration009 } from './009-drop-pending-credentials.js';
export interface Migration {
version: number;
@@ -20,9 +20,9 @@ const migrations: Migration[] = [
migration002,
migration003,
migration004,
migration005,
migration007,
migration008,
migration009,
];
export function runMigrations(db: Database.Database): void {

View File

@@ -169,9 +169,9 @@ CREATE TABLE IF NOT EXISTS destinations (
-- Default reply routing for this session. Single-row table (id=1).
-- Host overwrites on every container wake from the session's messaging_group
-- and thread_id. Container reads it in send_message / ask_user_question /
-- trigger_credential_collection to default the channel/thread of outbound
-- messages when the agent doesn't specify an explicit destination.
-- and thread_id. Container reads it in send_message / ask_user_question to
-- default the channel/thread of outbound messages when the agent doesn't
-- specify an explicit destination.
CREATE TABLE IF NOT EXISTS session_routing (
id INTEGER PRIMARY KEY CHECK (id = 1),
channel_type TEXT,

View File

@@ -865,12 +865,6 @@ async function handleSystemAction(
break;
}
case 'request_credential': {
const { handleCredentialRequest } = await import('./credentials.js');
await handleCredentialRequest(content, session);
break;
}
default:
log.warn('Unknown system action', { action });
}

View File

@@ -19,13 +19,6 @@ import {
startOneCLIApprovalHandler,
stopOneCLIApprovalHandler,
} from './onecli-approvals.js';
import {
getCredentialForModal,
handleCredentialChannelUnsupported,
handleCredentialReject,
handleCredentialSubmit,
setCredentialDeliveryAdapter,
} from './credentials.js';
import { routeInbound } from './router.js';
import {
getPendingQuestion,
@@ -93,22 +86,6 @@ async function main(): Promise<void> {
log.error('Failed to handle question response', { questionId, err });
});
},
getCredentialForModal,
onCredentialReject(credentialId) {
handleCredentialReject(credentialId).catch((err) =>
log.error('Failed to handle credential reject', { credentialId, err }),
);
},
onCredentialSubmit(credentialId, value) {
handleCredentialSubmit(credentialId, value).catch((err) =>
log.error('Failed to handle credential submit', { credentialId, err }),
);
},
onCredentialChannelUnsupported(credentialId) {
handleCredentialChannelUnsupported(credentialId).catch((err) =>
log.error('Failed to handle credential channel-unsupported', { credentialId, err }),
);
},
};
});
@@ -135,7 +112,6 @@ async function main(): Promise<void> {
},
};
setDeliveryAdapter(deliveryAdapter);
setCredentialDeliveryAdapter(deliveryAdapter);
// 5. Start delivery polls
startActiveDeliveryPoll();

View File

@@ -1,84 +0,0 @@
/**
* OneCLI secrets facade.
*
* @onecli-sh/sdk 0.3.1 does not yet expose secret management. This module wraps
* the `onecli secrets create` CLI so the rest of the codebase can call
* `createSecret(...)` with the same shape we expect the SDK to ship with.
*
* When the SDK adds secret management, replace the body of `createSecret()`
* with the SDK call and delete the CLI plumbing below. Nothing else in
* NanoClaw should need to change — the public types here mirror the
* anticipated SDK surface.
*/
import { execFile } from 'child_process';
export interface CreateSecretInput {
name: string;
type: 'generic' | 'anthropic';
value: string;
hostPattern: string;
pathPattern?: string;
headerName?: string;
valueFormat?: string;
/**
* Agent scoping. Not supported by current OneCLI CLI — included here so
* callers can pass it today and it becomes live when the SDK adds scoping.
*/
agentId?: string;
}
export interface CreateSecretResponse {
id: string;
name: string;
hostPattern: string;
}
export class OneCLISecretError extends Error {
constructor(message: string) {
super(message);
this.name = 'OneCLISecretError';
}
}
export async function createSecret(input: CreateSecretInput): Promise<CreateSecretResponse> {
const payload: Record<string, unknown> = {
name: input.name,
type: input.type,
value: input.value,
hostPattern: input.hostPattern,
};
if (input.pathPattern) payload.pathPattern = input.pathPattern;
if (input.headerName || input.valueFormat) {
payload.injectionConfig = {
...(input.headerName && { headerName: input.headerName }),
...(input.valueFormat && { valueFormat: input.valueFormat }),
};
}
const stdout = await runOnecli(['secrets', 'create', '--json', JSON.stringify(payload)]);
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
throw new OneCLISecretError(`onecli returned non-JSON: ${stdout.slice(0, 200)}`);
}
const result = parsed as { id?: string; name?: string; hostPattern?: string; error?: string };
if (result.error) throw new OneCLISecretError(result.error);
return {
id: result.id ?? '',
name: result.name ?? input.name,
hostPattern: result.hostPattern ?? input.hostPattern,
};
}
function runOnecli(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile('onecli', args, { timeout: 15_000 }, (error, stdout, stderr) => {
if (error) {
reject(new OneCLISecretError(stderr || error.message));
return;
}
resolve(stdout);
});
});
}

View File

@@ -157,28 +157,6 @@ export interface PendingApproval {
options_json: string;
}
// ── Pending credentials (central DB) ──
export type PendingCredentialStatus = 'pending' | 'submitted' | 'saved' | 'rejected' | 'failed';
export interface PendingCredential {
id: string;
agent_group_id: string;
session_id: string | null;
name: string;
type: 'generic' | 'anthropic';
host_pattern: string;
path_pattern: string | null;
header_name: string | null;
value_format: string | null;
description: string | null;
channel_type: string;
platform_id: string;
platform_message_id: string | null;
status: PendingCredentialStatus;
created_at: string;
}
// ── Agent destinations (central DB) ──
export interface AgentDestination {