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
@@ -17,19 +17,7 @@ Before any Vercel operation, verify auth:
vercel whoami --token placeholder
```
If this fails with an auth error, collect the credential:
```
trigger_credential_collection(
name: "Vercel API Token",
hostPattern: "api.vercel.com",
headerName: "Authorization",
valueFormat: "Bearer {value}",
description: "Vercel personal access token. Create one at https://vercel.com/account/tokens"
)
```
Then retry `vercel whoami`.
If this fails with an auth error, ask the user to add a Vercel token to OneCLI. They can create one at https://vercel.com/account/tokens and register it via `onecli secrets create` on the host. Once added, retry `vercel whoami`.
## Deploying
@@ -96,7 +84,7 @@ echo "value" | vercel env add VAR_NAME production --token placeholder
| `Error: Rate limited` | Wait and retry. Don't loop — report to user |
| `Error: You have reached your project limit` | User needs to upgrade Vercel plan or delete unused projects |
| `ENOTFOUND api.vercel.com` | Network issue. Check proxy connectivity |
| Auth error after `vercel whoami` | Credential may be expired. Re-run `trigger_credential_collection` |
| Auth error after `vercel whoami` | Credential may be expired. Ask the user to refresh the Vercel token in OneCLI |
## Building Websites — Delegate to Frontend Engineer
+4 -6
View File
@@ -38,7 +38,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
## Central DB
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, pending_credentials, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
## Key Files
@@ -53,7 +53,6 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
| `src/access.ts` | `pickApprover`, `pickApprovalDelivery`, admin resolution for `NANOCLAW_ADMIN_USER_IDS` |
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/credentials.ts` | `trigger_credential_collection` host side — modal, OneCLI write-back |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
@@ -65,16 +64,15 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
## Self-Modification
Two tiers of agent self-modification today:
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server` / `request_rebuild`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Admin approval, rebuild, container restart. `container/agent-runner/src/mcp-tools/self-mod.ts`.
2. **`trigger_credential_collection`** — user provides an API key via a secure modal; value goes straight into OneCLI and never enters agent context. `src/credentials.ts`.
A third tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-secrets.ts`, `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
## Skills
-1
View File
@@ -14,7 +14,6 @@ export {
markFailed,
getMessageIn,
findQuestionResponse,
findCredentialResponse,
} from './messages-in.js';
export type { MessageInRow } from './messages-in.js';
export { writeMessageOut, getUndeliveredMessages } from './messages-out.js';
@@ -113,19 +113,3 @@ 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;
}
@@ -1,132 +0,0 @@
/**
* 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 an API key / OAuth token / secret from the user for a third-party service. Research the service first so you pass the correct host pattern, header name, and value format. The value is injected straight into OneCLI and never enters your context. Blocks until saved/rejected/failed.',
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,7 +15,6 @@ 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}`);
@@ -27,7 +26,6 @@ const allTools: McpToolDefinition[] = [
...interactiveTools,
...agentTools,
...selfModTools,
...credentialTools,
];
const toolMap = new Map<string, McpToolDefinition>();
+2 -14
View File
@@ -19,19 +19,7 @@ Before any Vercel operation, verify auth:
vercel whoami --token placeholder
```
If this fails with an auth error, collect the credential:
```
trigger_credential_collection(
name: "Vercel API Token",
hostPattern: "api.vercel.com",
headerName: "Authorization",
valueFormat: "Bearer {value}",
description: "Vercel personal access token. Create one at https://vercel.com/account/tokens"
)
```
Then retry `vercel whoami`.
If this fails with an auth error, ask the user to add a Vercel token to OneCLI. They can create one at https://vercel.com/account/tokens and register it via `onecli secrets create` on the host. Once added, retry `vercel whoami`.
## Deploying
@@ -98,7 +86,7 @@ echo "value" | vercel env add VAR_NAME production --token placeholder
| `Error: Rate limited` | Wait and retry. Don't loop — report to user |
| `Error: You have reached your project limit` | User needs to upgrade Vercel plan or delete unused projects |
| `ENOTFOUND api.vercel.com` | Network issue. Check proxy connectivity |
| Auth error after `vercel whoami` | Credential may be expired. Re-run `trigger_credential_collection` |
| Auth error after `vercel whoami` | Credential may be expired. Ask the user to refresh the Vercel token in OneCLI |
## Building Websites — Delegate to Frontend Engineer
+1 -4
View File
@@ -176,14 +176,13 @@ flowchart TB
subgraph OneCLI["OneCLI Gateway (0.3.1)"]
Vault["Agent Vault<br/>secrets + OAuth"]
Approvals["configureManualApproval"]
SecretsFacade["onecli-secrets.ts<br/>credential collection"]
end
subgraph Session["Per-Session Container"]
direction TB
PollLoop["Poll Loop<br/>container/agent-runner"]
Provider["Claude Agent SDK<br/>(codex / opencode planned)"]
MCP["MCP Tools<br/>send_message · send_file · edit_message<br/>send_card · ask_user_question · schedule_task<br/>create_agent · install_packages · add_mcp_server<br/>request_rebuild · trigger_credential_collection"]
MCP["MCP Tools<br/>send_message · send_file · edit_message<br/>send_card · ask_user_question · schedule_task<br/>create_agent · install_packages · add_mcp_server<br/>request_rebuild"]
InDB[("inbound.db<br/>host writes · even seq")]
OutDB[("outbound.db<br/>container writes · odd seq")]
end
@@ -212,8 +211,6 @@ flowchart TB
Runner -.mounts.-> Folder
MCP -.approval.-> Approvals
Approvals --> Central
MCP -.credential req.-> SecretsFacade
SecretsFacade --> Vault
Provider -.API calls.-> Vault
</pre>
</div>
+1 -4
View File
@@ -26,14 +26,13 @@ flowchart TB
subgraph OneCLI["OneCLI Gateway (0.3.1)"]
Vault["Agent Vault<br/>secrets + OAuth"]
Approvals["configureManualApproval<br/>-> pending_approvals"]
SecretsFacade["src/onecli-secrets.ts<br/>credential collection"]
end
subgraph Session["Per-Session Container (Docker / Apple Container)"]
direction TB
PollLoop["Poll Loop<br/>(container/agent-runner)"]
Provider["Claude Agent SDK<br/>(providers: claude, mock, todo: codex/opencode)"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server, request_rebuild,<br/>trigger_credential_collection"]
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server, request_rebuild"]
Skills["Container Skills<br/>(container/skills/)"]
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
@@ -66,8 +65,6 @@ flowchart TB
Runner -.mounts.-> Folder
MCP -.approval.-> Approvals
Approvals --> Central
MCP -.credential req.-> SecretsFacade
SecretsFacade --> Vault
Provider -.API calls.-> Vault
```
-8
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. */
+1 -99
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;
-8
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;
-300
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');
}
-33
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);
}
-7
View File
@@ -58,10 +58,3 @@ export {
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
createPendingCredential,
getPendingCredential,
updatePendingCredentialStatus,
updatePendingCredentialMessageId,
deletePendingCredential,
} from './credentials.js';
@@ -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);
`);
},
};
@@ -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;
`);
},
};
+2 -2
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 {
+3 -3
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,
-6
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 });
}
-24
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();
-84
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);
});
});
}
-22
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 {