diff --git a/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md index 5e40b52d4..f7a3ba7ad 100644 --- a/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +++ b/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8025b6c0c..d6552cb48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/container/agent-runner/src/db/index.ts b/container/agent-runner/src/db/index.ts index f7ebc069e..d1b97fedf 100644 --- a/container/agent-runner/src/db/index.ts +++ b/container/agent-runner/src/db/index.ts @@ -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'; diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 81923508b..da1a8ddb3 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -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; -} diff --git a/container/agent-runner/src/mcp-tools/credentials.ts b/container/agent-runner/src/mcp-tools/credentials.ts deleted file mode 100644 index dee1743f6..000000000 --- a/container/agent-runner/src/mcp-tools/credentials.ts +++ /dev/null @@ -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 { - 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]; diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index c3bdc1c95..555128064 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -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(); diff --git a/container/skills/vercel-cli/SKILL.md b/container/skills/vercel-cli/SKILL.md index d855f70b1..275acb376 100644 --- a/container/skills/vercel-cli/SKILL.md +++ b/container/skills/vercel-cli/SKILL.md @@ -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 diff --git a/docs/v2-architecture-diagram.html b/docs/v2-architecture-diagram.html index 5f345f5e4..2f62957f2 100644 --- a/docs/v2-architecture-diagram.html +++ b/docs/v2-architecture-diagram.html @@ -176,14 +176,13 @@ flowchart TB subgraph OneCLI["OneCLI Gateway (0.3.1)"] Vault["Agent Vault
secrets + OAuth"] Approvals["configureManualApproval"] - SecretsFacade["onecli-secrets.ts
credential collection"] end subgraph Session["Per-Session Container"] direction TB PollLoop["Poll Loop
container/agent-runner"] Provider["Claude Agent SDK
(codex / opencode planned)"] - MCP["MCP Tools
send_message · send_file · edit_message
send_card · ask_user_question · schedule_task
create_agent · install_packages · add_mcp_server
request_rebuild · trigger_credential_collection"] + MCP["MCP Tools
send_message · send_file · edit_message
send_card · ask_user_question · schedule_task
create_agent · install_packages · add_mcp_server
request_rebuild"] InDB[("inbound.db
host writes · even seq")] OutDB[("outbound.db
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 diff --git a/docs/v2-architecture-diagram.md b/docs/v2-architecture-diagram.md index c833f6f1e..bc3509891 100644 --- a/docs/v2-architecture-diagram.md +++ b/docs/v2-architecture-diagram.md @@ -26,14 +26,13 @@ flowchart TB subgraph OneCLI["OneCLI Gateway (0.3.1)"] Vault["Agent Vault
secrets + OAuth"] Approvals["configureManualApproval
-> pending_approvals"] - SecretsFacade["src/onecli-secrets.ts
credential collection"] end subgraph Session["Per-Session Container (Docker / Apple Container)"] direction TB PollLoop["Poll Loop
(container/agent-runner)"] Provider["Claude Agent SDK
(providers: claude, mock, todo: codex/opencode)"] - MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server, request_rebuild,
trigger_credential_collection"] + MCP["MCP Tools
send_message, send_file, edit_message,
add_reaction, send_card, ask_user_question,
schedule_task, create_agent,
install_packages, add_mcp_server, request_rebuild"] Skills["Container Skills
(container/skills/)"] InDB[("inbound.db
host writes
even seq
messages_in
destinations
processing_ack")] OutDB[("outbound.db
container writes
odd seq
messages_out
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 ``` diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index b745bc3f3..55efde174 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -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. */ diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index a5944fc9e..6c0a39217 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -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:: - 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; diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index dfe030119..28ac10c6c 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -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; diff --git a/src/credentials.ts b/src/credentials.ts deleted file mode 100644 index 831ef3d53..000000000 --- a/src/credentials.ts +++ /dev/null @@ -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, session: Session): Promise { - 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 { - 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 { - 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 { - 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 { - 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'); -} diff --git a/src/db/credentials.ts b/src/db/credentials.ts deleted file mode 100644 index 887cf96b1..000000000 --- a/src/db/credentials.ts +++ /dev/null @@ -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); -} diff --git a/src/db/index.ts b/src/db/index.ts index cfb280276..eaabe5bb7 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -58,10 +58,3 @@ export { deletePendingApproval, getPendingApprovalsByAction, } from './sessions.js'; -export { - createPendingCredential, - getPendingCredential, - updatePendingCredentialStatus, - updatePendingCredentialMessageId, - deletePendingCredential, -} from './credentials.js'; diff --git a/src/db/migrations/005-pending-credentials.ts b/src/db/migrations/005-pending-credentials.ts deleted file mode 100644 index beeb3d7dd..000000000 --- a/src/db/migrations/005-pending-credentials.ts +++ /dev/null @@ -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); - `); - }, -}; diff --git a/src/db/migrations/009-drop-pending-credentials.ts b/src/db/migrations/009-drop-pending-credentials.ts new file mode 100644 index 000000000..4b1e74df6 --- /dev/null +++ b/src/db/migrations/009-drop-pending-credentials.ts @@ -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; + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index dee9998ea..d8ee9ec85 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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 { diff --git a/src/db/schema.ts b/src/db/schema.ts index 2b53ed64c..044d71713 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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, diff --git a/src/delivery.ts b/src/delivery.ts index e050955d8..12559c2ab 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -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 }); } diff --git a/src/index.ts b/src/index.ts index 594488426..08c15640d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { 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 { }, }; setDeliveryAdapter(deliveryAdapter); - setCredentialDeliveryAdapter(deliveryAdapter); // 5. Start delivery polls startActiveDeliveryPoll(); diff --git a/src/onecli-secrets.ts b/src/onecli-secrets.ts deleted file mode 100644 index 747249678..000000000 --- a/src/onecli-secrets.ts +++ /dev/null @@ -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 { - const payload: Record = { - 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 { - 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); - }); - }); -} diff --git a/src/types.ts b/src/types.ts index 22067c6c1..ad14441e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {