feat(v2): OneCLI 0.3.1 — approvals, credential collection, threaded routing

Three features built on top of @onecli-sh/sdk 0.3.1, landed together because
they share wiring surfaces (session DB schema, delivery dispatcher, Chat SDK
bridge, channel adapter contract).

## OneCLI manual-approval handler

* `src/onecli-approvals.ts` — long-polls OneCLI via the SDK's
  `configureManualApproval`; on each request, delivers an `ask_question` card
  to the admin agent group's first messaging group, persists a
  `pending_approvals` row, and waits on an in-memory Promise resolved by the
  admin's button click or an expiry timer. Expired cards are edited to
  "Expired (...)" and a startup sweep flushes any rows left over from a
  previous process.
* Short 11-byte approval id (`oa-<8 base36>`) instead of the SDK's UUID so the
  Telegram 64-byte `callback_data` limit is respected; the OneCLI UUID stays
  in the persisted payload for audit.
* Migration 003 consolidated: `pending_approvals` now has the OneCLI-aware
  columns from the start (`agent_group_id`, `channel_type`, `platform_id`,
  `platform_message_id`, `expires_at`, `status`), `session_id` relaxed to
  nullable so cross-session approvals fit.
* `handleQuestionResponse` in `src/index.ts` now routes OneCLI approvals
  through `resolveOneCLIApproval` before falling back to the
  session-bound approval path.

## Credential collection from chat

New `trigger_credential_collection` MCP tool — the agent researches a
third-party API, calls the tool with `{name, hostPattern, headerName,
valueFormat, description}`, and blocks until the host reports saved, rejected,
or failed. The credential value never enters the agent's context: the user
submits it into a Chat SDK Modal on the host side, the host writes it to
OneCLI via a thin facade (`src/onecli-secrets.ts` — shells out to
`onecli secrets create`, shape mirrors the SDK we expect upstream), and only
the status string flows back to the container via a system message.

* `src/credentials.ts` — host-side handler: delivers the card to the
  conversation's own channel (not the admin channel — credential collection
  is a user-facing flow, distinct from admin approval), persists a
  `pending_credentials` row, drives the submit → `createSecret` → notify
  pipeline. Falls back gracefully when the channel doesn't support modals.
* `src/db/credentials.ts` + migration 005: `pending_credentials` table.
* `src/channels/chat-sdk-bridge.ts`: renders a `credential_request` card,
  handles the `nccr:` action prefix by opening a Modal with a TextInput,
  registers an `onModalSubmit` handler for the `nccm:` callback prefix.
* `container/agent-runner/src/mcp-tools/credentials.ts`: the blocking MCP
  tool, mirroring the `ask_user_question` polling pattern.
* `container/agent-runner/src/db/messages-in.ts`: `findCredentialResponse`
  helper to pick up the system message the host writes back.

## Threaded adapter routing

The destination layer previously didn't carry thread context, so agent replies
to Discord always landed in the root channel regardless of which thread the
inbound came from.

* `ChannelAdapter.supportsThreads: boolean` — declared by every channel skill
  at `createChatSdkBridge`. Threaded: Discord, Slack, Teams, Google Chat,
  Linear, GitHub, Webex. Non-threaded: Telegram, WhatsApp Cloud, Matrix,
  Resend, iMessage.
* `src/router.ts`: non-threaded adapters strip `threadId` at ingest (threads
  collapse to channel-level sessions). Threaded adapters override the
  wiring's `session_mode` to `'per-thread'` so each thread = a session
  (except `agent-shared`, which is preserved as a cross-channel intent the
  adapter can't know about).
* `session_routing` table in `inbound.db` — single-row default reply routing
  written by the host on every container wake from
  `session.messaging_group_id` + `session.thread_id`. Forward-compat
  `CREATE TABLE IF NOT EXISTS` handles older session DBs lazily.
* `container/agent-runner/src/db/session-routing.ts` — container-side reader.
* `send_message` / `send_file` / `ask_user_question` / `send_card` /
  scheduling tools all default their routing (channel, platform, **and**
  thread) from the session when no explicit `to` is given. Explicit `to`
  uses the destination's channel with `thread_id = null` (cross-destination
  sends start a new conversation elsewhere).
* `poll-loop.ts::sendToDestination` (the final-text single-destination
  shortcut) now inherits `thread_id` from `RoutingContext` too — this was
  the root cause of Discord replies landing in the root channel even after
  `send_message` was wired correctly.

## Related cleanups

* `src/container-runner.ts`: OneCLI agent identifier switched from the lossy
  folder-derived string to `agent_group.id`, making `getAgentGroup(externalId)`
  a trivial reverse lookup for per-agent scoping.
* `wakeContainer` race fix via an in-flight promise map — concurrent wakes
  during the async buildContainerArgs / OneCLI `applyContainerConfig` window
  no longer double-spawn containers against the same session directory.
* `src/db/db-v2.test.ts`: dropped the brittle `expect(row.v).toBe(N)` schema
  version assertion — it had to be bumped on every migration addition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-11 17:18:21 +03:00
parent 9dc8bc5d99
commit e92b245399
43 changed files with 1391 additions and 70 deletions

33
src/db/credentials.ts Normal file
View File

@@ -0,0 +1,33 @@
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,12 +58,6 @@ describe('migrations', () => {
runMigrations(db);
});
it('should track schema version', () => {
const db = initTestDb();
runMigrations(db);
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number };
expect(row.v).toBe(4);
});
});
// ── Agent Groups ──

View File

@@ -36,4 +36,16 @@ export {
createPendingQuestion,
getPendingQuestion,
deletePendingQuestion,
createPendingApproval,
getPendingApproval,
updatePendingApprovalStatus,
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
createPendingCredential,
getPendingCredential,
updatePendingCredentialStatus,
updatePendingCredentialMessageId,
deletePendingCredential,
} from './credentials.js';

View File

@@ -1,18 +1,39 @@
import type { Migration } from './index.js';
/**
* `pending_approvals` table — host-side records for any approval-requiring
* request. Used by:
* - install_packages / request_rebuild / add_mcp_server (session-bound,
* `session_id` set, status stays at default 'pending' until handled)
* - OneCLI credential approvals from the SDK `configureManualApproval`
* callback (session_id may be null, action='onecli_credential').
*
* The OneCLI-specific columns (`agent_group_id`, `channel_type`, `platform_id`,
* `platform_message_id`, `expires_at`, `status`) let the host edit the admin
* card when a request expires and sweep stale rows on startup.
*/
export const migration003: Migration = {
version: 3,
name: 'pending-approvals',
up(db) {
db.exec(`
CREATE TABLE pending_approvals (
approval_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
request_id TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
approval_id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id),
request_id TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
agent_group_id TEXT REFERENCES agent_groups(id),
channel_type TEXT,
platform_id TEXT,
platform_message_id TEXT,
expires_at TEXT,
status TEXT NOT NULL DEFAULT 'pending'
);
CREATE INDEX idx_pending_approvals_action_status
ON pending_approvals(action, status);
`);
},
};

View File

@@ -0,0 +1,34 @@
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

@@ -5,6 +5,7 @@ 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';
export interface Migration {
version: number;
@@ -12,7 +13,7 @@ export interface Migration {
up: (db: Database.Database) => void;
}
const migrations: Migration[] = [migration001, migration002, migration003, migration004];
const migrations: Migration[] = [migration001, migration002, migration003, migration004, migration005];
export function runMigrations(db: Database.Database): void {
db.exec(`

View File

@@ -114,6 +114,18 @@ CREATE TABLE destinations (
platform_id TEXT, -- for type='channel'
agent_group_id TEXT -- for type='agent'
);
-- 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.
CREATE TABLE session_routing (
id INTEGER PRIMARY KEY CHECK (id = 1),
channel_type TEXT,
platform_id TEXT,
thread_id TEXT
);
`;
/** Container-owned: outbound messages + processing acknowledgments. */

View File

@@ -93,13 +93,26 @@ export function deletePendingQuestion(questionId: string): void {
// ── Pending Approvals ──
export function createPendingApproval(pa: PendingApproval): void {
export function createPendingApproval(pa: Partial<PendingApproval> & Pick<PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at'>): void {
getDb()
.prepare(
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at)
VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at)`,
`INSERT INTO pending_approvals
(approval_id, session_id, request_id, action, payload, created_at,
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status)
VALUES
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status)`,
)
.run(pa);
.run({
session_id: null,
agent_group_id: null,
channel_type: null,
platform_id: null,
platform_message_id: null,
expires_at: null,
status: 'pending',
...pa,
});
}
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
@@ -108,6 +121,14 @@ export function getPendingApproval(approvalId: string): PendingApproval | undefi
| undefined;
}
export function updatePendingApprovalStatus(approvalId: string, status: PendingApproval['status']): void {
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
}
export function deletePendingApproval(approvalId: string): void {
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
}
export function getPendingApprovalsByAction(action: string): PendingApproval[] {
return getDb().prepare('SELECT * FROM pending_approvals WHERE action = ?').all(action) as PendingApproval[];
}