mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af0d7c6153 | |||
| 4ef7d7367c | |||
| 97d986b1db | |||
| 76984ef395 |
+2
-2
@@ -4,8 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **The guard seam (guarded-actions phase 2).** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The four handler registries (ncl commands, delivery actions, response handlers, message interceptors) wrap their handlers at registration, so the guarded path is the only path by construction, and a registry-walking conformance test fails CI on any unmapped mutating entry. The structural baseline is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the baseline) is deferred to phase 3, where the generalized rules table arrives with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural baseline on every replay. Three outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the live baseline re-check); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed); **(c)** the channel-registration free-text name reply re-checks approver eligibility at reply time — a privilege revoked between the click and the reply, or a registration that vanished meanwhile, now refuses instead of acting. Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
|
||||
- **Guard conformance is now a boot invariant, not just a CI test.** The host refuses to start (upgrade-tripwire posture: clear banner + exit 1) if any privileged command or delivery action is registered without a guard-catalog mapping. CI can't see skill-installed code — `/add-*` skills register handlers on machines where the test suite never runs — so the same registry walk the conformance test uses (`src/guard-conformance.ts`, one shared exemption list) now runs in `main()` before the host accepts a message, and a bad registration crashes at skill-install time instead of running unguarded. Companion hardening: re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded.
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
|
||||
@@ -66,6 +66,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain baselines are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. All four handler registries require a guard spec or an explicit `unguarded(<reason>)` declaration at the registration site. Approved replays carry the approval row as a grant and re-check the structural baseline. Boot check = grant continuations only (`src/guard-conformance.ts`); Policy-as-data is deferred to guarded-actions phase 3. Conformance test: `src/guard/conformance.test.ts` |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
@@ -105,7 +106,6 @@ ncl help
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
@@ -138,7 +138,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
|
||||
@@ -120,24 +120,6 @@ export function markCompleted(ids: string[]): void {
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ack task messages whose pre-task script gated the run. The reason decides
|
||||
* the ack: `gated` (wakeAgent=false) is the monitor working as designed → a
|
||||
* plain `completed`; `error` (broken script) → `script-skip:error`, which the
|
||||
* host's ack sync records as a FAILED run so recurrence can read the trailing
|
||||
* failed streak off the occurrence rows and back the series off.
|
||||
*/
|
||||
export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): void {
|
||||
if (skips.length === 0) return;
|
||||
const db = getOutboundDb();
|
||||
const stmt = db.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed');
|
||||
})();
|
||||
}
|
||||
|
||||
/** Mark a single message as failed — writes to processing_ack in outbound.db. */
|
||||
export function markFailed(id: string): void {
|
||||
getOutboundDb()
|
||||
|
||||
@@ -141,22 +141,3 @@ export function getUndeliveredMessages(): MessageOutRow[] {
|
||||
)
|
||||
.all() as MessageOutRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a deliberate send with this exact destination + text already exists
|
||||
* (an MCP send_message row from the current turn). Used by the task-fire
|
||||
* final-text dispatcher to drop the turn-final <message> echo of a send the
|
||||
* agent already made — the dedup happens where the duplication originates.
|
||||
*/
|
||||
export function hasIdenticalSend(platformId: string, channelType: string, text: string): boolean {
|
||||
const row = getOutboundDb()
|
||||
.prepare(
|
||||
`SELECT 1 FROM messages_out
|
||||
WHERE platform_id = $platform_id AND channel_type = $channel_type
|
||||
AND (in_reply_to IS NULL OR in_reply_to = '')
|
||||
AND json_extract(content, '$.text') = $text
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get({ $platform_id: platformId, $channel_type: channelType, $text: text });
|
||||
return row != null;
|
||||
}
|
||||
|
||||
@@ -105,11 +105,13 @@ function buildDestinationsSection(): string {
|
||||
const lines = ['## Sending messages', ''];
|
||||
if (all.length === 1) {
|
||||
const d = all[0];
|
||||
lines.push(`Your destination is \`${d.name}\`${destinationLabel(d)}.`);
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||
} else {
|
||||
lines.push('You can send messages to the following destinations:', '');
|
||||
for (const d of all) {
|
||||
lines.push(`- \`${d.name}\`${destinationLabel(d)}`);
|
||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||
lines.push(`- \`${d.name}\`${label}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
@@ -126,10 +128,3 @@ function buildDestinationsSection(): string {
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function destinationLabel(d: DestinationEntry): string {
|
||||
const parts: string[] = [];
|
||||
if (d.channelType) parts.push(d.channelType);
|
||||
if (d.displayName && d.displayName !== d.name) parts.push(d.displayName);
|
||||
return parts.length > 0 ? ` (${parts.join(' · ')})` : '';
|
||||
}
|
||||
|
||||
@@ -98,11 +98,6 @@ export interface RoutingContext {
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
inReplyTo: string | null;
|
||||
/** Batch is a task fire — explicit `<message to>` sends must NOT inherit
|
||||
* inReplyTo (the series id), or the host's task-fire suppression drops
|
||||
* them as turn-final echoes: zero delivery. Deliberate sends are
|
||||
* in_reply_to-null, same as the out-of-process MCP send_message path. */
|
||||
taskFire: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +111,6 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
||||
channelType: first?.channel_type ?? null,
|
||||
threadId: first?.thread_id ?? null,
|
||||
inReplyTo: first?.id ?? null,
|
||||
taskFire: messages.length > 0 && messages.every((m) => m.kind === 'task'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ Your CLI access may be scoped. Run `ncl help` to see which resources are availab
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, append-log | Scheduled tasks for your agent group |
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
@@ -34,12 +33,11 @@ Additional resources (available under `global` scope only): messaging-groups, wi
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Scheduling work** — `ncl tasks create`, then `ncl tasks list/get/update/cancel/pause/resume/delete`; `ncl tasks run <id>` fires one extra run now (testing) without changing the schedule. At the end of each task run, `ncl tasks append-log --msg "…"` to record what happened (host-timestamped, not a message).
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Most write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it. `ncl tasks` is the exception: an agent can manage its own group tasks without approval.
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
|
||||
### Approval flow
|
||||
|
||||
@@ -63,13 +61,6 @@ ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
ncl tasks list
|
||||
# Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid).
|
||||
# For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots.
|
||||
ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *"
|
||||
# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary.
|
||||
# This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session.
|
||||
ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups restart
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* at module scope, and append the import here. No central list.
|
||||
*/
|
||||
import './core.js';
|
||||
import './scheduling.js';
|
||||
import './interactive.js';
|
||||
import './agents.js';
|
||||
import './self-mod.js';
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
## Task scheduling (`ncl tasks`)
|
||||
## Task scheduling (`schedule_task`)
|
||||
|
||||
Use `ncl tasks` for one-shot and recurring tasks. A task runs in this agent group's system session, not in the current chat session, so when it fires you must choose a destination explicitly with `<message to="name">...</message>` or `send_message({ to: "name", ... })`.
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
|
||||
|
||||
Pass `--name "<short label>"` on create to get a readable task id (e.g. `--name "sales briefing"` → `sales-briefing-a25c`); without it ids are `t-<hex>`.
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
|
||||
|
||||
Common commands:
|
||||
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
|
||||
|
||||
### How it works
|
||||
|
||||
1. Provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first
|
||||
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script directly to verify it works:
|
||||
|
||||
```bash
|
||||
ncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"
|
||||
ncl tasks list
|
||||
ncl tasks get ping-a25c # includes run count, failures, and recent run-log lines
|
||||
ncl tasks run ping-a25c # fire once now without changing the schedule (testing)
|
||||
ncl tasks update ping-a25c --prompt "New instructions"
|
||||
ncl tasks pause ping-a25c
|
||||
ncl tasks resume ping-a25c
|
||||
ncl tasks cancel ping-a25c # or --all as a kill switch
|
||||
ncl tasks delete ping-a25c
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
Use good judgement on whether it's appropriate to check in with the user about the task prompt before task creation, and if so, whether to share verbatim or a description of it.
|
||||
### When NOT to use scripts
|
||||
|
||||
`--process-after` accepts UTC timestamps or naive local timestamps interpreted in the instance timezone (shown in the `<context timezone="..."/>` header).
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
|
||||
|
||||
Run `ncl tasks create --help` for schedules, options, and pre-task gate scripts (checks that run before you wake).
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants a task to run more than a few times a day and a script can't be used:
|
||||
|
||||
- Explain that each time the task fires it uses API credits and risks rate limits
|
||||
- Suggest adjusting the task requirements in a way that will allow you to use a script
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Scheduling MCP tools: schedule_task, list_tasks, cancel_task, pause_task, resume_task.
|
||||
*
|
||||
* With the two-DB split, the container cannot write to inbound.db (host-owned).
|
||||
* Scheduling operations are sent as system actions via messages_out — the host
|
||||
* reads them during delivery and applies the changes to inbound.db.
|
||||
*/
|
||||
import { getInboundDb } from '../db/connection.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function routing() {
|
||||
return getSessionRouting();
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export const scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
|
||||
},
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
|
||||
},
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfterIn = args.processAfter as string;
|
||||
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
|
||||
|
||||
let processAfter: string;
|
||||
try {
|
||||
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
|
||||
processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${processAfterIn}`);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
const recurrence = (args.recurrence as string) || null;
|
||||
const script = (args.script as string) || null;
|
||||
|
||||
// Write as a system action — host will insert into inbound.db
|
||||
writeMessageOut({
|
||||
id,
|
||||
kind: 'system',
|
||||
platform_id: r.platform_id,
|
||||
channel_type: r.channel_type,
|
||||
thread_id: r.thread_id,
|
||||
content: JSON.stringify({
|
||||
action: 'schedule_task',
|
||||
taskId: id,
|
||||
prompt,
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`schedule_task: ${id} at ${processAfter}${recurrence ? ` (recurring: ${recurrence})` : ''}`);
|
||||
return ok(`Task scheduled (id: ${id}, runs at: ${processAfter}${recurrence ? `, recurrence: ${recurrence}` : ''})`);
|
||||
},
|
||||
};
|
||||
|
||||
export const listTasks: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'list_tasks',
|
||||
description:
|
||||
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const status = args.status as string | undefined;
|
||||
const db = getInboundDb();
|
||||
// One row per series — the live (pending or paused) occurrence. Recurring
|
||||
// tasks accumulate one completed row per firing plus one live follow-up;
|
||||
// exposing the whole pile to the agent is noisy and confuses task identity
|
||||
// ("which id do I cancel?"). The series_id is the stable handle.
|
||||
//
|
||||
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
|
||||
// query, the bare columns take values from the row that contains that max
|
||||
// — that's how we pick "the latest live row per series" in one pass.
|
||||
let rows;
|
||||
if (status) {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status = ?
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all(status);
|
||||
} else {
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND status IN ('pending', 'paused')
|
||||
GROUP BY series_id
|
||||
ORDER BY process_after ASC`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
if ((rows as unknown[]).length === 0) return ok('No tasks found.');
|
||||
|
||||
const lines = (rows as Array<{ id: string; status: string; process_after: string | null; recurrence: string | null; content: string }>).map((r) => {
|
||||
const content = JSON.parse(r.content);
|
||||
const prompt = (content.prompt as string || '').slice(0, 80);
|
||||
return `- ${r.id} [${r.status}] at=${r.process_after || 'now'} ${r.recurrence ? `recur=${r.recurrence} ` : ''}→ ${prompt}`;
|
||||
});
|
||||
|
||||
return ok(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
export const cancelTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'cancel_task',
|
||||
description: 'Cancel a scheduled task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to cancel' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
// Write as a system action — host will update inbound.db
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'cancel_task', taskId }),
|
||||
});
|
||||
|
||||
log(`cancel_task: ${taskId}`);
|
||||
return ok(`Task cancellation requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const pauseTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'pause_task',
|
||||
description: 'Pause a scheduled task. It will not run until resumed.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to pause' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'pause_task', taskId }),
|
||||
});
|
||||
|
||||
log(`pause_task: ${taskId}`);
|
||||
return ok(`Task pause requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const resumeTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'resume_task',
|
||||
description: 'Resume a paused task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Task ID to resume' },
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'resume_task', taskId }),
|
||||
});
|
||||
|
||||
log(`resume_task: ${taskId}`);
|
||||
return ok(`Task resume requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const updateTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'update_task',
|
||||
description:
|
||||
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
|
||||
prompt: { type: 'string', description: 'New task prompt (optional)' },
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
|
||||
},
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
|
||||
},
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'New pre-agent script (optional). Pass empty string to clear.',
|
||||
},
|
||||
},
|
||||
required: ['taskId'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const taskId = args.taskId as string;
|
||||
if (!taskId) return err('taskId is required');
|
||||
|
||||
const update: Record<string, unknown> = { taskId };
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (typeof args.processAfter === 'string') {
|
||||
try {
|
||||
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
|
||||
update.processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${args.processAfter}`);
|
||||
}
|
||||
}
|
||||
// Empty string clears recurrence/script; undefined leaves them as-is.
|
||||
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
|
||||
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
|
||||
|
||||
if (Object.keys(update).length === 1) return err('at least one field to update is required');
|
||||
|
||||
writeMessageOut({
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({ action: 'update_task', ...update }),
|
||||
});
|
||||
|
||||
log(`update_task: ${taskId}`);
|
||||
return ok(`Task update requested: ${taskId}`);
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask]);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, markScriptSkipped, type MessageInRow } from './db/messages-in.js';
|
||||
import { hasIdenticalSend, writeMessageOut } from './db/messages-out.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
@@ -207,15 +207,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Without the scheduling module, the marker block is empty, `keep`
|
||||
// falls back to `normalMessages`, and no gating happens.
|
||||
let keep: MessageInRow[] = normalMessages;
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
let skipped: string[] = [];
|
||||
// MODULE-HOOK:scheduling-pre-task:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(normalMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task:end
|
||||
|
||||
@@ -238,7 +238,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped.map((s) => s.id));
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
@@ -401,15 +401,15 @@ export async function processQuery(
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: Array<{ id: string; reason: string }> = [];
|
||||
let skipped: string[] = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markScriptSkipped(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.map((s) => s.id).join(', ')}`);
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
@@ -650,15 +650,6 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
// Task fires: an explicitly-addressed final-text block is either the echo of
|
||||
// an MCP send the agent already made this turn (drop it HERE, where the
|
||||
// duplication originates) or the agent's only deliberate send (write it
|
||||
// in_reply_to-null like the MCP path, or the host's task-fire suppression
|
||||
// would discard it — zero delivery).
|
||||
if (routing.taskFire && hasIdenticalSend(platformId, channelType, body)) {
|
||||
log(`Dropping turn-final echo of an already-sent task message to ${dest.name}`);
|
||||
return;
|
||||
}
|
||||
// Resolve thread_id per-destination from the most recent inbound message
|
||||
// that came from this same channel+platform. In agent-shared sessions,
|
||||
// different destinations have different thread contexts — using a single
|
||||
@@ -666,7 +657,7 @@ function sendToDestination(dest: DestinationEntry, body: string, routing: Routin
|
||||
const destRouting = resolveDestinationThread(channelType, platformId);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: destRouting?.inReplyTo ?? (routing.taskFire ? null : routing.inReplyTo),
|
||||
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
|
||||
@@ -17,7 +17,7 @@ function log(msg: string): void {
|
||||
// Code's interactive UI and would hang here).
|
||||
//
|
||||
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
|
||||
// scheduling via `ncl tasks`.
|
||||
// scheduling via mcp__nanoclaw__schedule_task.
|
||||
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
|
||||
// real answer — we have mcp__nanoclaw__ask_user_question that persists
|
||||
// the question and blocks on the real reply.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Container leg of the script-failure backoff chain, tested at unit level so
|
||||
* the e2e suite doesn't need a live multi-sweep scenario for it:
|
||||
*
|
||||
* script error → applyPreTaskScripts skips with reason 'error'
|
||||
* → markScriptSkipped acks `script-skip:error` in outbound.db
|
||||
* (gated → plain 'completed': the monitor working as designed).
|
||||
*
|
||||
* The host leg (ack → FAILED run → streak backoff) is pinned in
|
||||
* src/db/session-db.test.ts and src/modules/scheduling/recurrence.test.ts —
|
||||
* both sides pin the literal 'script-skip:error'; if either renames it, its
|
||||
* own test goes red.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getPendingMessages, markScriptSkipped } from '../db/messages-in.js';
|
||||
import { applyPreTaskScripts } from './task-script.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertTask(id: string, script: string) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, trigger, content)
|
||||
VALUES (?, 'task', datetime('now'), 'pending', 1, ?)`,
|
||||
)
|
||||
.run(id, JSON.stringify({ prompt: 'monitor', script }));
|
||||
}
|
||||
|
||||
const ackStatus = (id: string): string | undefined =>
|
||||
(getOutboundDb().prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id) as { status: string } | undefined)
|
||||
?.status;
|
||||
|
||||
describe('script-skip ack chain (container leg)', () => {
|
||||
it('an erroring script skips with reason "error" and acks script-skip:error', async () => {
|
||||
insertTask('t-err', 'echo boom >&2; exit 1');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-err', reason: 'error' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-err')).toBe('script-skip:error');
|
||||
});
|
||||
|
||||
it('a deliberate wakeAgent=false gate acks plain completed — never backs off', async () => {
|
||||
insertTask('t-gated', 'echo \'{"wakeAgent": false}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(keep).toHaveLength(0);
|
||||
expect(skipped).toEqual([{ id: 't-gated', reason: 'gated' }]);
|
||||
|
||||
markScriptSkipped(skipped);
|
||||
expect(ackStatus('t-gated')).toBe('completed');
|
||||
});
|
||||
|
||||
it('wakeAgent=true keeps the task and enriches the prompt with script data', async () => {
|
||||
insertTask('t-wake', 'echo \'{"wakeAgent": true, "data": {"alerts": 2}}\'');
|
||||
const { keep, skipped } = await applyPreTaskScripts(getPendingMessages());
|
||||
|
||||
expect(skipped).toHaveLength(0);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(JSON.parse(keep[0].content).scriptOutput).toEqual({ alerts: 2 });
|
||||
});
|
||||
});
|
||||
@@ -64,26 +64,21 @@ export async function runScript(script: string, taskId: string): Promise<ScriptR
|
||||
});
|
||||
}
|
||||
|
||||
/** Why a script gated its task: deliberate wakeAgent=false vs a broken script. */
|
||||
export type ScriptSkipReason = 'gated' | 'error';
|
||||
|
||||
export interface TaskScriptOutcome {
|
||||
keep: MessageInRow[];
|
||||
skipped: Array<{ id: string; reason: ScriptSkipReason }>;
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-task scripts for any task messages that carry one, serially.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`,
|
||||
* with the reason. The caller acks these as script-skips (not plain
|
||||
* completions) so the host can count consecutive failures and back off.
|
||||
* - Errors / missing output / wakeAgent=false → task id added to `skipped`.
|
||||
* - wakeAgent=true → content JSON is mutated to carry `scriptOutput`, so the
|
||||
* formatter renders it into the prompt.
|
||||
* Non-task messages and tasks without scripts pass through unchanged.
|
||||
*/
|
||||
export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<TaskScriptOutcome> {
|
||||
const keep: MessageInRow[] = [];
|
||||
const skipped: Array<{ id: string; reason: ScriptSkipReason }> = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind !== 'task') {
|
||||
@@ -111,9 +106,9 @@ export async function applyPreTaskScripts(messages: MessageInRow[]): Promise<Tas
|
||||
touchHeartbeat();
|
||||
|
||||
if (!result || !result.wakeAgent) {
|
||||
const reason: ScriptSkipReason = result ? 'gated' : 'error';
|
||||
log(`task ${msg.id} skipped: ${reason === 'gated' ? 'wakeAgent=false' : 'script error/no output'}`);
|
||||
skipped.push({ id: msg.id, reason });
|
||||
const reason = result ? 'wakeAgent=false' : 'script error/no output';
|
||||
log(`task ${msg.id} skipped: ${reason}`);
|
||||
skipped.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,11 +134,11 @@ A personal AI assistant accessible via messaging, with minimal custom code.
|
||||
|
||||
### Scheduler
|
||||
- Built-in scheduler runs on the host, spawns containers for task execution
|
||||
- `ncl tasks` provides scheduling commands
|
||||
- Commands: `list`, `get`, `create`, `update`, `cancel`, `pause`, `resume`, `delete`, `run`, `append-log`
|
||||
- Custom `nanoclaw` MCP server (inside container) provides scheduling tools
|
||||
- Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message`
|
||||
- Tasks stored in SQLite with run history
|
||||
- Scheduler loop checks for due tasks every minute
|
||||
- Tasks execute in the agent group's system session
|
||||
- Tasks execute Claude Agent SDK in containerized group context
|
||||
|
||||
### Web Access
|
||||
- Built-in WebSearch and WebFetch tools
|
||||
|
||||
+20
-3
@@ -579,7 +579,12 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group'
|
||||
```
|
||||
User: @Andy remind me every Monday at 9am to review the weekly metrics
|
||||
|
||||
Claude: [runs ncl tasks create --prompt "Send a reminder to review weekly metrics. Be encouraging!" --process-after "2024-02-05T09:00:00" --recurrence "0 9 * * 1"]
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Send a reminder to review weekly metrics. Be encouraging!",
|
||||
"schedule_type": "cron",
|
||||
"schedule_value": "0 9 * * 1"
|
||||
}
|
||||
|
||||
Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
@@ -589,7 +594,12 @@ Claude: Done! I'll remind you every Monday at 9am.
|
||||
```
|
||||
User: @Andy at 5pm today, send me a summary of today's emails
|
||||
|
||||
Claude: [runs ncl tasks create --prompt "Search for today's emails, summarize the important ones, and send the summary to the group." --process-after "2024-01-31T17:00:00"]
|
||||
Claude: [calls mcp__nanoclaw__schedule_task]
|
||||
{
|
||||
"prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.",
|
||||
"schedule_type": "once",
|
||||
"schedule_value": "2024-01-31T17:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Managing Tasks
|
||||
@@ -610,11 +620,18 @@ From main channel:
|
||||
|
||||
### NanoClaw MCP (built-in)
|
||||
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. Scheduled task management lives in `ncl tasks`, not MCP.
|
||||
The `nanoclaw` MCP server is created dynamically per agent call with the current group's context.
|
||||
|
||||
**Available Tools:**
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `schedule_task` | Schedule a recurring or one-time task |
|
||||
| `list_tasks` | Show tasks (group's tasks, or all if main) |
|
||||
| `get_task` | Get task details and run history |
|
||||
| `update_task` | Modify task prompt or schedule |
|
||||
| `pause_task` | Pause a task |
|
||||
| `resume_task` | Resume a paused task |
|
||||
| `cancel_task` | Delete a task |
|
||||
| `send_message` | Send a message to the group via its channel |
|
||||
|
||||
---
|
||||
|
||||
@@ -634,18 +634,52 @@ destination is of type `agent`. `resolveRouting` maps it to a `messages_out` row
|
||||
`channel_type: 'agent'` and `platform_id` set to the target agent group id; the host
|
||||
validates the send and routes it into the target session's `inbound.db`.
|
||||
|
||||
#### ncl tasks
|
||||
#### schedule_task
|
||||
|
||||
Schedule, inspect, and modify one-shot or recurring tasks.
|
||||
Schedule a one-shot or recurring task.
|
||||
|
||||
```bash
|
||||
ncl tasks create --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks list
|
||||
ncl tasks update <series_id> --prompt "..."
|
||||
ncl tasks cancel <series_id>
|
||||
```typescript
|
||||
{
|
||||
name: 'schedule_task',
|
||||
params: {
|
||||
prompt: string, // task prompt
|
||||
processAfter: string, // ISO timestamp for first run
|
||||
recurrence?: string, // cron expression (optional)
|
||||
script?: string, // pre-agent script (optional)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: the host writes `messages_in` task rows into the agent group's system session (`thread_id = system:tasks`). The host sweep wakes that system-session container when a task is due. The task agent chooses its destination at fire time by emitting `<message to="name">...</message>` or using `send_message`.
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
List active scheduled/recurring tasks.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'list_tasks',
|
||||
params: {}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
Modify a scheduled task.
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'cancel_task',
|
||||
params: { taskId: string }
|
||||
}
|
||||
// pause_task: set status = 'paused' (new status value for recurring tasks)
|
||||
// resume_task: set status = 'pending'
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### create_agent
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ flowchart TB
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude — the only one registered in trunk;<br/>opencode ships via the /add-opencode skill)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>create_agent, install_packages, add_mcp_server<br/>CLI: ncl tasks"]
|
||||
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"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>delivered<br/>destinations<br/>session_routing")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>processing_ack<br/>session_state<br/>container_state<br/>heartbeat file")]
|
||||
|
||||
@@ -904,10 +904,13 @@ MCP tools write to the container's own `outbound.db`. Anything that needs a chan
|
||||
|
||||
(There is no `send_to_agent` tool — agent-to-agent is `send_message` to an `agent` destination.)
|
||||
|
||||
**Scheduling**: scheduled-task management is not an MCP surface — it lives on
|
||||
`ncl tasks` (create/list/get/update/cancel/pause/resume/run/append-log). Due
|
||||
task rows live in the agent group's system session and are woken by the host
|
||||
sweep.
|
||||
**Scheduling** (all emit `kind: 'system'` actions except the read-only `list_tasks`):
|
||||
|
||||
| Tool | What it does |
|
||||
|------|-------------|
|
||||
| `schedule_task` | `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `inbound.db` (read-only) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` / `update_task` | matching `action`; host updates the live `messages_in` row(s) |
|
||||
|
||||
**Central-DB / self-modification** (`kind: 'system'` actions; host authorizes, often via admin approval):
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# `ncl tasks` migration
|
||||
|
||||
## Detect
|
||||
|
||||
If an agent mentions `schedule_task`, `list_tasks`, `update_task`, `cancel_task`, `pause_task`, or `resume_task`, it is using the old scheduling MCP surface.
|
||||
|
||||
A subtler symptom of a stale container image: the agent reports a task as scheduled, but `ncl tasks list` shows nothing and the host log has `Unknown system action` — the old image's `schedule_task` call is acknowledged in-container and then dropped by the new host. The fix below (rebuild + restart) resolves it.
|
||||
|
||||
## Why
|
||||
|
||||
Scheduling moved to `ncl tasks`. New tasks are stored in a per-agent-group system session and run there, so a scheduled task does not wake an existing chat session. When it fires, the agent must choose the delivery destination explicitly.
|
||||
|
||||
## Fix
|
||||
|
||||
Rebuild and restart agent containers so they load the updated MCP tool list and instructions:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
On Linux, restart with `systemctl --user restart nanoclaw`.
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
ncl tasks list
|
||||
ncl tasks create --group <agent_group_id> --prompt "..." --process-after "2026-01-15T09:00:00" --recurrence "0 9 * * *"
|
||||
ncl tasks update --id <series_id> --prompt "..."
|
||||
ncl tasks cancel --id <series_id>
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
Run `ncl tasks list`. New task rows should show a system `session_id`, not the chat session that requested the task.
|
||||
|
||||
## Legacy tasks (scheduled before this update)
|
||||
|
||||
Tasks created through the old MCP tools live in the **chat session** that created them, not in a per-series system session. They are unaffected by this update: they keep firing and delivering exactly as before. Two things to know:
|
||||
|
||||
- An agent's own `ncl tasks list` (group scope) shows only its group's task rows; from the **host**, unscoped `ncl tasks list` enumerates everything, and `--session <id>` narrows to one session — that is how you find and manage legacy rows (`ncl tasks cancel --session <chat_session_id> --all` to clear a chat session's tasks).
|
||||
- The `messages_in` status enum now includes `cancelled` (cancel marks the row and clears its recurrence rather than deleting it). Custom code that exhaustively switches on task status needs the new arm.
|
||||
|
||||
## Rollback
|
||||
|
||||
Order matters:
|
||||
|
||||
1. Remove tasks created through `ncl tasks` (`ncl tasks list` / `delete`) — they live in per-series system sessions the old code doesn't know about.
|
||||
2. **Wait one sweep (≤60s)** so the host closes the now-empty task sessions.
|
||||
3. Then revert the update and rebuild the container image.
|
||||
|
||||
Reverting before the task sessions are collected leaves system sessions behind that the old `findSessionByAgentGroup` (which has no system-session exclusion) can resolve as the group's session — mis-routing agent-to-agent messages into a dead task thread.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.41",
|
||||
"version": "2.1.40",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="223k tokens, 111% of context window">
|
||||
<title>223k tokens, 111% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="213k tokens, 106% of context window">
|
||||
<title>213k tokens, 106% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">223k</text>
|
||||
<text x="71" y="14">223k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">213k</text>
|
||||
<text x="71" y="14">213k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -15,7 +15,7 @@ vi.mock('./log.js', () => ({
|
||||
}));
|
||||
|
||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||
import { ensureContainerConfig, updateContainerConfigScalars } from './db/container-configs.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { PERSONA_PREPEND_FILE } from './group-persona.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
@@ -91,28 +91,3 @@ describe('composeGroupClaudeMd persona prepend', () => {
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, '.claude-fragments', 'persona.md'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeGroupClaudeMd scheduling instructions (ncl tasks reach-in)', () => {
|
||||
// Red-on-delete guard for the `scheduling`/`cli` exclusion at the
|
||||
// module-fragment loop: the agent is taught `ncl tasks` iff it has ncl.
|
||||
it('imports module-scheduling.md at the default cli_scope', () => {
|
||||
const ag = group('ag-sched', 'sched-group');
|
||||
seed(ag);
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
expect(importsOf(ag.folder)).toContain('@./.claude-fragments/module-scheduling.md');
|
||||
});
|
||||
|
||||
it('excludes module-scheduling.md (and module-cli.md) when cli_scope is disabled', () => {
|
||||
const ag = group('ag-sched-off', 'sched-group-off');
|
||||
seed(ag);
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'disabled' });
|
||||
|
||||
composeGroupClaudeMd(ag);
|
||||
|
||||
const imports = importsOf(ag.folder);
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-scheduling.md');
|
||||
expect(imports).not.toContain('@./.claude-fragments/module-cli.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,12 +80,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in module fragments — every MCP/CLI module that ships a
|
||||
// Built-in module fragments — every MCP tool source file that ships a
|
||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||
// use that module's tools (`ncl tasks`, install_packages, etc.).
|
||||
// Skip ncl-dependent instructions when cli_scope is disabled. `scheduling`
|
||||
// teaches `ncl tasks`, so it is just as dead as `cli` itself when the agent
|
||||
// has no ncl — dispatch rejects every cli_request and ncl is excluded.
|
||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
||||
// Skip cli.instructions.md when cli_scope is disabled.
|
||||
const cliDisabled = configRow?.cli_scope === 'disabled';
|
||||
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||
if (fs.existsSync(mcpToolsHostDir)) {
|
||||
@@ -93,7 +91,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||
if (!match) continue;
|
||||
const moduleName = match[1];
|
||||
if ((moduleName === 'cli' || moduleName === 'scheduling') && cliDisabled) continue;
|
||||
if (moduleName === 'cli' && cliDisabled) continue;
|
||||
desired.set(`module-${moduleName}.md`, {
|
||||
type: 'symlink',
|
||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
||||
|
||||
@@ -327,6 +327,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.operations.list) {
|
||||
register({
|
||||
name: `${def.plural}-list`,
|
||||
action: `${def.plural}.list`,
|
||||
description: `List all ${def.plural}.`,
|
||||
access: def.operations.list,
|
||||
resource: def.plural,
|
||||
@@ -339,6 +340,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.operations.get) {
|
||||
register({
|
||||
name: `${def.plural}-get`,
|
||||
action: `${def.plural}.get`,
|
||||
description: `Get a ${def.name} by ID.`,
|
||||
access: def.operations.get,
|
||||
resource: def.plural,
|
||||
@@ -351,6 +353,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.operations.create) {
|
||||
register({
|
||||
name: `${def.plural}-create`,
|
||||
action: `${def.plural}.create`,
|
||||
description: `Create a new ${def.name}.`,
|
||||
access: def.operations.create,
|
||||
resource: def.plural,
|
||||
@@ -362,6 +365,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.operations.update) {
|
||||
register({
|
||||
name: `${def.plural}-update`,
|
||||
action: `${def.plural}.update`,
|
||||
description: `Update a ${def.name}.`,
|
||||
access: def.operations.update,
|
||||
resource: def.plural,
|
||||
@@ -373,6 +377,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
if (def.operations.delete) {
|
||||
register({
|
||||
name: `${def.plural}-delete`,
|
||||
action: `${def.plural}.delete`,
|
||||
description: `Delete a ${def.name}.`,
|
||||
access: def.operations.delete,
|
||||
resource: def.plural,
|
||||
@@ -389,6 +394,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
const declared = op.args;
|
||||
register({
|
||||
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
|
||||
action: `${def.plural}.${verb.replace(/ /g, '.')}`,
|
||||
description: op.description,
|
||||
access: op.access,
|
||||
resource: def.plural,
|
||||
|
||||
+42
-37
@@ -8,52 +8,57 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { registerDeliveryAction } from '../delivery.js';
|
||||
import { unguarded } from '../guard/index.js';
|
||||
import { insertMessage } from '../db/session-db.js';
|
||||
import { log } from '../log.js';
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { RequestFrame } from './frame.js';
|
||||
import type { Session } from '../types.js';
|
||||
|
||||
registerDeliveryAction('cli_request', async (content, session, inDb) => {
|
||||
const requestId = content.requestId as string;
|
||||
const command = content.command as string;
|
||||
const args = (content.args as Record<string, unknown>) ?? {};
|
||||
registerDeliveryAction(
|
||||
'cli_request',
|
||||
async (content, session, inDb) => {
|
||||
const requestId = content.requestId as string;
|
||||
const command = content.command as string;
|
||||
const args = (content.args as Record<string, unknown>) ?? {};
|
||||
|
||||
if (!requestId || !command) {
|
||||
log.warn('cli_request missing requestId or command', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
if (!requestId || !command) {
|
||||
log.warn('cli_request missing requestId or command', { sessionId: session.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const req: RequestFrame = { id: requestId, command, args };
|
||||
const ctx = {
|
||||
caller: 'agent' as const,
|
||||
sessionId: session.id,
|
||||
agentGroupId: session.agent_group_id,
|
||||
messagingGroupId: session.messaging_group_id ?? '',
|
||||
};
|
||||
const req: RequestFrame = { id: requestId, command, args };
|
||||
const ctx = {
|
||||
caller: 'agent' as const,
|
||||
sessionId: session.id,
|
||||
agentGroupId: session.agent_group_id,
|
||||
messagingGroupId: session.messaging_group_id ?? '',
|
||||
};
|
||||
|
||||
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
|
||||
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
|
||||
|
||||
const response = await dispatch(req, ctx);
|
||||
const response = await dispatch(req, ctx);
|
||||
|
||||
// Write response to inbound.db so the container can read it.
|
||||
// trigger=0: don't wake the agent — this is an inline response to a tool call.
|
||||
insertMessage(inDb, {
|
||||
id: `cli-resp-${requestId}`,
|
||||
kind: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
type: 'cli_response',
|
||||
requestId,
|
||||
frame: response,
|
||||
}),
|
||||
processAfter: null,
|
||||
recurrence: null,
|
||||
trigger: 0,
|
||||
});
|
||||
// Write response to inbound.db so the container can read it.
|
||||
// trigger=0: don't wake the agent — this is an inline response to a tool call.
|
||||
insertMessage(inDb, {
|
||||
id: `cli-resp-${requestId}`,
|
||||
kind: 'system',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
type: 'cli_response',
|
||||
requestId,
|
||||
frame: response,
|
||||
}),
|
||||
processAfter: null,
|
||||
recurrence: null,
|
||||
trigger: 0,
|
||||
});
|
||||
|
||||
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
|
||||
});
|
||||
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
|
||||
},
|
||||
unguarded('transport envelope — every inner command is guarded at dispatch'),
|
||||
);
|
||||
|
||||
+82
-23
@@ -9,6 +9,7 @@ const approvalState = vi.hoisted(() => ({
|
||||
| ((args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
approval: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>),
|
||||
@@ -18,6 +19,7 @@ const approvalState = vi.hoisted(() => ({
|
||||
handler: (args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
approval: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>,
|
||||
@@ -43,8 +45,11 @@ vi.mock('../db/agent-groups.js', () => ({
|
||||
}));
|
||||
|
||||
const mockGetSession = vi.fn();
|
||||
// The guard's grant check re-fetches the approval row to prove it's live.
|
||||
const mockGetPendingApproval = vi.fn();
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
|
||||
}));
|
||||
|
||||
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
|
||||
@@ -115,15 +120,6 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'tasks-list',
|
||||
description: 'test command (tasks resource)',
|
||||
resource: 'tasks',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'test command (wirings resource — not allowed)',
|
||||
@@ -227,7 +223,6 @@ beforeEach(() => {
|
||||
sessions: 'agent_group_id',
|
||||
destinations: 'agent_group_id',
|
||||
members: 'agent_group_id',
|
||||
tasks: 'agent_group_id',
|
||||
};
|
||||
mockGetResource.mockImplementation((plural: string) =>
|
||||
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
|
||||
@@ -374,19 +369,6 @@ describe('CLI scope enforcement', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('group: allows tasks, auto-fills --group', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'tasks-list', args: {} }, agentCtx());
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.group).toBe('g1');
|
||||
expect(data.echo.id).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
@@ -495,10 +477,20 @@ describe('CLI scope enforcement', () => {
|
||||
callerContext: ctx,
|
||||
});
|
||||
|
||||
// The approve path hands the handler the live approval row — the grant
|
||||
// the replay carries back into dispatch.
|
||||
const grantRow = {
|
||||
approval_id: 'appr-t1',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify(approval.payload),
|
||||
};
|
||||
mockGetPendingApproval.mockReturnValue(grantRow);
|
||||
|
||||
expect(approvalState.approvalHandler).toBeTypeOf('function');
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
approval: grantRow,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
@@ -507,6 +499,73 @@ describe('CLI scope enforcement', () => {
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Grant-carrying replay (the `approved: true` boolean no longer exists) ---
|
||||
|
||||
it('replay with a dead grant (row deleted) refuses instead of re-holding', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const ctx = agentCtx();
|
||||
await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
|
||||
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
|
||||
|
||||
mockGetPendingApproval.mockReturnValue(undefined); // resolution already deleted the row
|
||||
const notify = vi.fn();
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
approval: { approval_id: 'appr-dead', action: 'cli_command', payload: JSON.stringify(approval.payload) },
|
||||
userId: 'telegram:admin',
|
||||
notify,
|
||||
});
|
||||
|
||||
expect(approvalState.observedContexts).toHaveLength(0); // handler never ran
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1); // no second card
|
||||
expect(notify.mock.calls[0][0]).toContain('failed');
|
||||
});
|
||||
|
||||
it("a grant approved for one command doesn't transfer to another", async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
// A live cli_command row, but held for a DIFFERENT command.
|
||||
const grantRow = {
|
||||
approval_id: 'appr-other',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify({ frame: { command: 'members-add' } }),
|
||||
};
|
||||
mockGetPendingApproval.mockReturnValue(grantRow);
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
|
||||
grant: grantRow as never,
|
||||
});
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('forbidden');
|
||||
expect(resp.error.message).toContain('grant');
|
||||
}
|
||||
expect(approvalState.observedContexts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a fabricated grant object without a live row is refused', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetPendingApproval.mockReturnValue(undefined);
|
||||
|
||||
const forged = {
|
||||
approval_id: 'appr-forged',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify({ frame: { command: 'approval-context-command' } }),
|
||||
};
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, agentCtx(), {
|
||||
grant: forged as never,
|
||||
});
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.code).toBe('forbidden');
|
||||
expect(approvalState.requestApproval).not.toHaveBeenCalled(); // refusal, not a fresh hold
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
|
||||
+41
-42
@@ -3,23 +3,37 @@
|
||||
* the per-session DB poller (container caller) call dispatch() with the
|
||||
* same frame and a transport-supplied CallerContext.
|
||||
*
|
||||
* Approval gating for risky calls from the container is the only branch
|
||||
* that differs by caller. Host callers and `open` commands run inline.
|
||||
* Every command passes the guard before its handler runs — the decision
|
||||
* (allow / hold / deny) comes from the command's catalog entry, derived at
|
||||
* registration (see cli/guard.ts). Dispatch keeps the mechanics: arg
|
||||
* auto-fill, the sessions-get existence oracle, `--help` interception,
|
||||
* parseArgs, and post-handler row filtering. An approved replay re-enters
|
||||
* here carrying the verified approval row as its grant — the guard re-checks
|
||||
* the structural baseline live, and the `approved: true` boolean no longer
|
||||
* exists.
|
||||
*/
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { getAgentGroup } from '../db/agent-groups.js';
|
||||
import { getSession } from '../db/sessions.js';
|
||||
import { guard, type GuardActor } from '../guard/index.js';
|
||||
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||
import type { PendingApproval } from '../types.js';
|
||||
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { getResource } from './crud.js';
|
||||
import { listVerbs, renderVerbHelp } from './help-render.js';
|
||||
import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js';
|
||||
import { commandGuard, listCommands, lookup } from './registry.js';
|
||||
|
||||
type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
approved?: boolean;
|
||||
/** Verified approval row when a command is replayed after approval. */
|
||||
grant?: PendingApproval;
|
||||
};
|
||||
|
||||
function actorFor(ctx: CallerContext): GuardActor {
|
||||
return ctx.caller === 'host'
|
||||
? { kind: 'host' }
|
||||
: { kind: 'agent', agentGroupId: ctx.agentGroupId, sessionId: ctx.sessionId };
|
||||
}
|
||||
|
||||
export async function dispatch(
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
@@ -54,43 +68,13 @@ export async function dispatch(
|
||||
return err(req.id, 'unknown-command', unknownCommandMessage(req.command));
|
||||
}
|
||||
|
||||
// CLI scope enforcement for agent callers
|
||||
// Group-scope mechanics for agent callers (visibility, not policy — the
|
||||
// allow/hold/deny decisions live in the guard baseline, cli/guard.ts).
|
||||
if (ctx.caller === 'agent') {
|
||||
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||
const cliScope = configRow?.cli_scope ?? 'group';
|
||||
|
||||
if (cliScope === 'disabled') {
|
||||
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
|
||||
}
|
||||
|
||||
if (cliScope === 'group') {
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
|
||||
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||
}
|
||||
|
||||
// Enforce group scope on all agent-group-related args.
|
||||
// Different resources use different arg names for the agent group ID.
|
||||
// Only check --id for resources where it IS the agent group ID.
|
||||
const groupArgs = ['agent_group_id', 'group'] as const;
|
||||
for (const key of groupArgs) {
|
||||
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
|
||||
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||
}
|
||||
}
|
||||
if (
|
||||
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
|
||||
req.args.id &&
|
||||
req.args.id !== ctx.agentGroupId
|
||||
) {
|
||||
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||
}
|
||||
|
||||
// Block cli_scope changes from group-scoped agents (privilege escalation)
|
||||
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
|
||||
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
|
||||
}
|
||||
|
||||
// Auto-fill agent-group-related args so the agent doesn't need
|
||||
// to pass its own group ID explicitly.
|
||||
const fill: Record<string, unknown> = {
|
||||
@@ -116,9 +100,19 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
const decision = guard(commandGuard(cmd.name), {
|
||||
actor: actorFor(ctx),
|
||||
payload: req.args,
|
||||
grant: opts.grant ?? null,
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
return err(req.id, 'forbidden', decision.reason);
|
||||
}
|
||||
|
||||
// `--help` interception: answer with the command's generated help instead of
|
||||
// executing. Placed after scope enforcement (a group-scoped agent can't probe
|
||||
// forbidden resources) and BEFORE approval gating — asking for help on an
|
||||
// executing. Placed after the guard's deny (a group-scoped agent can't probe
|
||||
// forbidden resources) and BEFORE hold execution — asking for help on an
|
||||
// approval-gated verb must never mint an approval card.
|
||||
if (req.args.help === true) {
|
||||
// Carry the help text in `human` too, so both clients print it verbatim
|
||||
@@ -127,7 +121,12 @@ export async function dispatch(
|
||||
return { id: req.id, ok: true, data: helpText, human: helpText };
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
|
||||
if (decision.effect === 'hold') {
|
||||
if (ctx.caller !== 'agent') {
|
||||
// Holds only arise for agent callers; anything else is a guard bug —
|
||||
// fail closed rather than card a ghost.
|
||||
return err(req.id, 'forbidden', decision.reason);
|
||||
}
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
return err(req.id, 'handler-error', 'Session not found.');
|
||||
@@ -215,10 +214,10 @@ export async function dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
|
||||
registerApprovalHandler('cli_command', async ({ payload, approval, notify }) => {
|
||||
const frame = payload.frame as RequestFrame;
|
||||
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
|
||||
const response = await dispatch(frame, callerContext, { approved: true });
|
||||
const response = await dispatch(frame, callerContext, { grant: approval });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Compact aligned-table renderer for `ncl tasks list` (human mode only).
|
||||
*
|
||||
* A task series is a recurring job; each fire is a run. This surfaces the run history
|
||||
* the raw row hides — run count, last/next fire, schedule — as an aligned table.
|
||||
* Driven by the enriched rows from listTasks (tasks.ts); the --json path is
|
||||
* untouched. `now` is injectable so the relative times are testable.
|
||||
*/
|
||||
|
||||
interface TaskListRow {
|
||||
series_id: string;
|
||||
schedule?: string | null;
|
||||
runs?: number;
|
||||
failed_runs?: number;
|
||||
last_run?: string | null;
|
||||
next_run?: string | null;
|
||||
status?: string;
|
||||
log?: string | null;
|
||||
created_at?: string | null;
|
||||
prompt?: string | null;
|
||||
}
|
||||
|
||||
const COLS = ['SERIES', 'SCHEDULE', 'RUNS', 'FAILED', 'LAST RUN', 'NEXT RUN', 'STATUS', 'AGE', 'PROMPT'] as const;
|
||||
|
||||
function parseMs(iso: string): number {
|
||||
return Date.parse(/[Z+]|[+-]\d\d:\d\d$/.test(iso) ? iso : iso + 'Z');
|
||||
}
|
||||
|
||||
/** "1m ago" / "in 30s" — coarse, human relative time. */
|
||||
function duration(ms: number): string {
|
||||
const s = Math.abs(ms) / 1000;
|
||||
if (s < 60) return `${Math.round(s)}s`;
|
||||
if (s < 3600) return `${Math.round(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
||||
return `${Math.round(s / 86400)}d`;
|
||||
}
|
||||
|
||||
function lastRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return `${duration(now - t)} ago`;
|
||||
}
|
||||
|
||||
function nextRun(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return t <= now ? 'due' : `in ${duration(t - now)}`;
|
||||
}
|
||||
|
||||
/** AGE — how long since the series was created. */
|
||||
function age(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '-';
|
||||
const t = parseMs(iso);
|
||||
return Number.isNaN(t) ? '-' : duration(now - t);
|
||||
}
|
||||
|
||||
function clip(s: string | null | undefined, n: number): string {
|
||||
const v = (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
return v.length > n ? v.slice(0, n - 1) + '…' : v;
|
||||
}
|
||||
|
||||
export function formatTasksTable(rows: TaskListRow[], now: number = Date.now()): string {
|
||||
if (!rows.length) return 'No tasks.';
|
||||
const body = rows.map((r) => [
|
||||
r.series_id, // full id, copy-pasteable into `ncl tasks get --id <…>`
|
||||
r.schedule || 'once',
|
||||
String(r.runs ?? 0),
|
||||
String(r.failed_runs ?? 0),
|
||||
lastRun(r.last_run, now),
|
||||
nextRun(r.next_run, now),
|
||||
r.status ?? '-',
|
||||
age(r.created_at, now),
|
||||
clip(r.prompt, 40),
|
||||
]);
|
||||
const widths = COLS.map((c, i) => Math.max(c.length, ...body.map((row) => row[i].length)));
|
||||
const line = (cells: string[]) =>
|
||||
cells
|
||||
.map((c, i) => c.padEnd(widths[i]))
|
||||
.join(' ')
|
||||
.trimEnd();
|
||||
return [line([...COLS]), ...body.map(line)].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* CLI guard adapter — the command registry's catalog derivation and
|
||||
* structural baseline, moved verbatim out of dispatch.ts (guarded-actions
|
||||
* phase 2). Declaration is registration: registry.register() derives one
|
||||
* catalog entry per command from the CommandDef itself; no second file is
|
||||
* edited when a command is added.
|
||||
*
|
||||
* The baseline carries today's decisions exactly:
|
||||
* host caller → allow (the 0600 socket is the auth story — in code,
|
||||
* unremovable by data);
|
||||
* cli_scope 'disabled' → deny; 'group' → resource allowlist, cross-group
|
||||
* arg denial, cli_scope-change denial;
|
||||
* access 'approval' for agent callers → hold for the group's admin chain.
|
||||
*
|
||||
* Arg auto-fill, the sessions-get existence oracle, and post-handler row
|
||||
* filtering stay in dispatch.ts — mechanics, not policy.
|
||||
*/
|
||||
import { getContainerConfig } from '../db/container-configs.js';
|
||||
import { ALLOW, DENY, HOLD, type GuardedActionSpec, type GuardInput } from '../guard/index.js';
|
||||
import { GROUP_SCOPE_RESOURCES, type CommandDef } from './registry.js';
|
||||
|
||||
/** Dotted catalog action name for a command. */
|
||||
export function commandGuardAction(cmd: Pick<CommandDef, 'name' | 'action'>): string {
|
||||
return cmd.action ?? `cli.${cmd.name}`;
|
||||
}
|
||||
|
||||
/** Catalog entry derived from a CommandDef at registration time. */
|
||||
export function commandGuardSpec(cmd: CommandDef): GuardedActionSpec {
|
||||
return {
|
||||
action: commandGuardAction(cmd),
|
||||
approvalAction: cmd.access === 'approval' ? 'cli_command' : undefined,
|
||||
// Bind a cli_command grant to the exact command it was approved for.
|
||||
grantMatches: (grant) => {
|
||||
try {
|
||||
const payload = JSON.parse(grant.payload) as { frame?: { command?: string } };
|
||||
return payload.frame?.command === cmd.name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (input) => commandBaseline(cmd, input),
|
||||
};
|
||||
}
|
||||
|
||||
function commandBaseline(cmd: CommandDef, input: GuardInput) {
|
||||
const { actor } = input;
|
||||
if (actor.kind === 'host') return ALLOW('host caller (trusted socket)');
|
||||
if (actor.kind !== 'agent') return DENY('CLI commands accept host or agent callers only.');
|
||||
|
||||
const args = input.payload;
|
||||
const cliScope = getContainerConfig(actor.agentGroupId)?.cli_scope ?? 'group';
|
||||
|
||||
if (cliScope === 'disabled') {
|
||||
return DENY('CLI access is disabled for this agent group.');
|
||||
}
|
||||
|
||||
if (cliScope === 'group') {
|
||||
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||
if (cmd.resource && !GROUP_SCOPE_RESOURCES.has(cmd.resource)) {
|
||||
return DENY(`CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||
}
|
||||
|
||||
// Enforce group scope on all agent-group-related args.
|
||||
// Different resources use different arg names for the agent group ID.
|
||||
// Only check --id for resources where it IS the agent group ID.
|
||||
for (const key of ['agent_group_id', 'group'] as const) {
|
||||
if (args[key] && args[key] !== actor.agentGroupId) {
|
||||
return DENY('CLI access is scoped to this agent group.');
|
||||
}
|
||||
}
|
||||
if ((cmd.resource === 'groups' || cmd.resource === 'destinations') && args.id && args.id !== actor.agentGroupId) {
|
||||
return DENY('CLI access is scoped to this agent group.');
|
||||
}
|
||||
|
||||
// Block cli_scope changes from group-scoped agents (privilege escalation)
|
||||
if (args.cli_scope !== undefined || args['cli-scope'] !== undefined) {
|
||||
return DENY('Cannot change cli_scope from a group-scoped agent.');
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.access === 'approval') {
|
||||
return HOLD(`agent-initiated "${cmd.name}" requires admin approval`);
|
||||
}
|
||||
|
||||
return ALLOW('open command');
|
||||
}
|
||||
+24
-1
@@ -8,6 +8,8 @@
|
||||
* registers the help commands, so the registry is populated before the host's
|
||||
* CLI server accepts connections.
|
||||
*/
|
||||
import { defineGuardedAction, type GuardedAction } from '../guard/index.js';
|
||||
import { commandGuardSpec } from './guard.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
/**
|
||||
@@ -15,7 +17,7 @@ import type { CallerContext } from './frame.js';
|
||||
* consumed by both dispatch enforcement and `ncl help` filtering, so the
|
||||
* agent is never shown a resource the gate would reject (or vice versa).
|
||||
*/
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members', 'tasks']);
|
||||
export const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
@@ -23,6 +25,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
access: Access;
|
||||
/**
|
||||
* Dotted guard-catalog action name (e.g. `roles.grant`,
|
||||
* `groups.config.add-mcp-server`). Set by registerResource from the
|
||||
* resource + verb; commands registered directly (help) fall back to
|
||||
* `cli.<name>`.
|
||||
*/
|
||||
action?: string;
|
||||
/**
|
||||
* The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher
|
||||
* only lets an agent run commands whose `resource` is on the whitelist
|
||||
@@ -52,12 +61,26 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
};
|
||||
|
||||
const registry = new Map<string, CommandDef>();
|
||||
const commandGuards = new Map<string, GuardedAction>();
|
||||
|
||||
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
|
||||
if (registry.has(def.name)) {
|
||||
throw new Error(`CLI command "${def.name}" already registered`);
|
||||
}
|
||||
registry.set(def.name, def as CommandDef);
|
||||
// Declaration is registration: every command gets a guard-catalog entry
|
||||
// derived from its own definition, in the same call that registers it — a
|
||||
// command cannot exist without a guard, and dispatch consults it by value.
|
||||
commandGuards.set(def.name, defineGuardedAction(commandGuardSpec(def as CommandDef)));
|
||||
}
|
||||
|
||||
/** The guard defined for a registered command — total for anything register() accepted. */
|
||||
export function commandGuard(name: string): GuardedAction {
|
||||
const g = commandGuards.get(name);
|
||||
if (!g) {
|
||||
throw new Error(`CLI command "${name}" has no guard — was it registered through register()?`);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
export function lookup(name: string): CommandDef | undefined {
|
||||
|
||||
@@ -58,39 +58,10 @@ registerResource({
|
||||
type: 'string',
|
||||
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
|
||||
},
|
||||
{ name: 'channel_type', type: 'string', description: 'Resolved channel type for channel destinations.' },
|
||||
{ name: 'display_name', type: 'string', description: 'Resolved chat title or agent name.' },
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||
],
|
||||
operations: {},
|
||||
operations: { list: 'open' },
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List destinations with resolved channel/title labels.',
|
||||
handler: async (args) => {
|
||||
const agentGroupId = (args.agent_group_id as string | undefined) ?? (args.id as string | undefined);
|
||||
const params: unknown[] = [];
|
||||
const where = agentGroupId ? 'WHERE ad.agent_group_id = ?' : '';
|
||||
if (agentGroupId) params.push(agentGroupId);
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT
|
||||
ad.agent_group_id,
|
||||
ad.local_name,
|
||||
ad.target_type,
|
||||
ad.target_id,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.channel_type ELSE NULL END AS channel_type,
|
||||
CASE WHEN ad.target_type = 'channel' THEN mg.name ELSE ag.name END AS display_name,
|
||||
ad.created_at
|
||||
FROM agent_destinations ad
|
||||
LEFT JOIN messaging_groups mg ON ad.target_type = 'channel' AND ad.target_id = mg.id
|
||||
LEFT JOIN agent_groups ag ON ad.target_type = 'agent' AND ad.target_id = ag.id
|
||||
${where}
|
||||
ORDER BY ad.agent_group_id, ad.local_name`,
|
||||
)
|
||||
.all(...params);
|
||||
},
|
||||
},
|
||||
add: {
|
||||
access: 'approval',
|
||||
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
|
||||
|
||||
@@ -14,4 +14,3 @@ import './user-dms.js';
|
||||
import './dropped-messages.js';
|
||||
import './approvals.js';
|
||||
import './sessions.js';
|
||||
import './tasks.js';
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-cli-tasks',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-cli-tasks/groups',
|
||||
TIMEZONE: 'UTC',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-tasks';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession, findSessionByAgentGroup, getSessionsByAgentGroup, taskThreadId } from '../../db/sessions.js';
|
||||
import { countDueMessages } from '../../db/session-db.js';
|
||||
import { inboundDbPath, initSessionFolder } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
import './tasks.js';
|
||||
import '../commands/index.js'; // registers tasks-help for the help-topic test
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id, folder: id, agent_provider: null, created_at: now() });
|
||||
}
|
||||
|
||||
function createChatSession(group: string, id: string): void {
|
||||
createSession({
|
||||
id,
|
||||
agent_group_id: group,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(group, id);
|
||||
}
|
||||
|
||||
function agentCtx(group = 'ag-1', session = 'chat-1'): CallerContext {
|
||||
return { caller: 'agent', agentGroupId: group, sessionId: session, messagingGroupId: 'mg-1' };
|
||||
}
|
||||
|
||||
describe('tasks CLI resource', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
createGroup('ag-1');
|
||||
createGroup('ag-2');
|
||||
createChatSession('ag-1', 'chat-1');
|
||||
createChatSession('ag-2', 'chat-2');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('create writes the task into the group system session, not the caller chat session', async () => {
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const created = resp.data as { series_id: string; session_id: string };
|
||||
expect(created.session_id).not.toBe('chat-1');
|
||||
|
||||
// The task lands in its own isolated per-series session, not the chat session.
|
||||
const sessions = getSessionsByAgentGroup('ag-1');
|
||||
const taskSession = sessions.find((s) => s.id === created.session_id);
|
||||
expect(taskSession?.thread_id).toBe(taskThreadId(created.series_id));
|
||||
|
||||
const chatDb = new Database(inboundDbPath('ag-1', 'chat-1'), { readonly: true });
|
||||
expect(chatDb.prepare("SELECT COUNT(*) AS count FROM messages_in WHERE kind = 'task'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
chatDb.close();
|
||||
|
||||
const systemDb = new Database(inboundDbPath('ag-1', created.session_id), { readonly: true });
|
||||
const row = systemDb.prepare("SELECT content FROM messages_in WHERE kind = 'task'").get() as { content: string };
|
||||
const content = JSON.parse(row.content);
|
||||
expect(content).toMatchObject({ originSessionId: 'chat-1' });
|
||||
expect(content.prompt).toContain('send a briefing');
|
||||
expect(content.prompt).toContain(`tasks/${created.series_id}.md`); // log-path hint injected
|
||||
systemDb.close();
|
||||
});
|
||||
|
||||
it('tasks-list attaches a server-rendered human table (so the container agent gets it too)', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'briefing', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
const resp = await dispatch({ id: 'l', command: 'tasks-list', args: {} }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
// Red-on-delete guard for the dispatch wiring: the host renders format-tasks
|
||||
// once and ships it as `human`, so the Bun container prints the aligned
|
||||
// table instead of a raw column dump (it cannot import the host formatter).
|
||||
expect(resp.human).toBeDefined();
|
||||
expect(resp.human).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN/);
|
||||
expect(resp.human).toContain('briefing-');
|
||||
});
|
||||
|
||||
it('recurrence more frequent than 4x/day is refused with the quota warning', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'spam', recurrence: '*/2 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.message).toContain('this task has not been scheduled');
|
||||
expect(resp.error.message).toContain('ncl tasks create --help');
|
||||
expect(resp.error.message).toContain('--dangerously-override-recurrence-limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('exactly 4 fires/day passes; the override flag bypasses the limit', async () => {
|
||||
const four = await dispatch(
|
||||
{ id: 'c4', command: 'tasks-create', args: { prompt: 'x', name: 'four', recurrence: '0 0,6,12,18 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(four.ok).toBe(true);
|
||||
|
||||
const overridden = await dispatch(
|
||||
{
|
||||
id: 'co',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'fast', recurrence: '*/30 * * * *', dangerously_override_recurrence_limit: true },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(overridden.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('a --script gate exempts frequent recurrence — the sanctioned monitor pattern', async () => {
|
||||
const scripted = await dispatch(
|
||||
{
|
||||
id: 'cs',
|
||||
command: 'tasks-create',
|
||||
args: {
|
||||
prompt: 'triage queue',
|
||||
name: 'watch',
|
||||
recurrence: '*/10 * * * *',
|
||||
script: 'echo {"wakeAgent": false}',
|
||||
},
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
expect(scripted.ok).toBe(true);
|
||||
|
||||
// update --recurrence on a task that already has a script: also exempt.
|
||||
if (!scripted.ok) return;
|
||||
const seriesId = (scripted.data as { series_id: string }).series_id;
|
||||
const upd = await dispatch(
|
||||
{ id: 'us', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(true);
|
||||
|
||||
// …but clearing the script in the same update re-arms the guard.
|
||||
const cleared = await dispatch(
|
||||
{ id: 'uc', command: 'tasks-update', args: { id: seriesId, recurrence: '*/5 * * * *', script: 'none' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(cleared.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('the limit also guards update --recurrence (no create-slow-then-update bypass)', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'c', command: 'tasks-create', args: { prompt: 'x', name: 'sneak', recurrence: '0 9 * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const seriesId = (created.data as { series_id: string }).series_id;
|
||||
|
||||
const upd = await dispatch(
|
||||
{ id: 'u', command: 'tasks-update', args: { id: seriesId, recurrence: '* * * * *' } },
|
||||
agentCtx(),
|
||||
);
|
||||
expect(upd.ok).toBe(false);
|
||||
if (!upd.ok) expect(upd.error.message).toContain('this task has not been scheduled');
|
||||
});
|
||||
|
||||
it('tasks create --help carries the script contract and the frequency-limit caveat', async () => {
|
||||
// --help and `tasks help create` render the same deep verb help.
|
||||
const resp = await dispatch({ id: 'h', command: 'tasks-create', args: { help: true } }, agentCtx());
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('wakeAgent');
|
||||
expect(text).toContain('Frequency limit');
|
||||
}
|
||||
});
|
||||
|
||||
it('agent-shared lookup skips the task system session', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'send a briefing', process_after: '2026-01-15T09:00:00Z' },
|
||||
},
|
||||
agentCtx(),
|
||||
);
|
||||
|
||||
expect(findSessionByAgentGroup('ag-1')?.id).toBe('chat-1');
|
||||
});
|
||||
|
||||
it('group-scoped agents cannot list tasks from another group session', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-1', command: 'tasks-list', args: { session: 'chat-2' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) {
|
||||
expect(resp.error.code).toBe('handler-error');
|
||||
expect(resp.error.message).toContain('session not found');
|
||||
}
|
||||
});
|
||||
|
||||
it('--name yields a short, readable, fs/thread-safe id', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'rn',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'Morning Joke!!', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const id = (r.data as { series_id: string }).series_id;
|
||||
expect(id).toMatch(/^morning-joke-[0-9a-f]{4}$/);
|
||||
expect(id).toMatch(/^[a-z0-9-]+$/); // safe as thread suffix / filename / --id
|
||||
});
|
||||
|
||||
it('no name yields a t-<hex> id', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rnn', command: 'tasks-create', args: { prompt: 'x', process_after: '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect((r.data as { series_id: string }).series_id).toMatch(/^t-[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it('recurring create derives the first run from the cron grid when --process-after is omitted', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'rec', command: 'tasks-create', args: { prompt: 'x', name: 'nightly', recurrence: '0 9 * * 1-5' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const task = r.data as { process_after: string; recurrence: string };
|
||||
expect(task.recurrence).toBe('0 9 * * 1-5');
|
||||
// First fire snapped onto the cron grid (TIMEZONE=UTC in this suite).
|
||||
const firstRun = new Date(task.process_after);
|
||||
expect(Number.isNaN(firstRun.getTime())).toBe(false);
|
||||
expect(firstRun.getUTCHours()).toBe(9);
|
||||
expect(firstRun.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('one-shot create still requires --process-after (nothing to derive it from)', async () => {
|
||||
const r = await dispatch(
|
||||
{ id: 'os', command: 'tasks-create', args: { prompt: 'x', name: 'once' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error.message).toContain('--process-after is required');
|
||||
});
|
||||
|
||||
it('run queues an extra immediate occurrence without consuming the scheduled one', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'c',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'pingable', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
const run = await dispatch({ id: 'r', command: 'tasks-run', args: { id: series_id } }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(run.ok).toBe(true);
|
||||
if (!run.ok) return;
|
||||
const fired = run.data as { series_id: string; row_id: string; status: string };
|
||||
expect(fired.series_id).toBe(series_id);
|
||||
expect(fired.row_id).not.toBe(series_id);
|
||||
expect(fired.status).toBe('pending');
|
||||
|
||||
const db = new Database(inboundDbPath('ag-1', session_id), { readonly: true });
|
||||
const pending = db
|
||||
.prepare(
|
||||
"SELECT id, recurrence, process_after FROM messages_in WHERE kind = 'task' AND status = 'pending' AND series_id = ?",
|
||||
)
|
||||
.all(series_id) as Array<{ id: string; recurrence: string | null; process_after: string }>;
|
||||
db.close();
|
||||
// Original scheduled row + the new run-now occurrence both still pending.
|
||||
expect(pending).toHaveLength(2);
|
||||
const runRow = pending.find((p) => p.id === fired.row_id);
|
||||
expect(runRow?.recurrence).toBeNull(); // never re-armed into a phantom series
|
||||
expect(new Date(runRow!.process_after).getTime()).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
|
||||
it('task object exposes origin_session_id and created_at', async () => {
|
||||
const r = await dispatch(
|
||||
{
|
||||
id: 'ro',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'x', name: 'o', process_after: '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const d = r.data as { origin_session_id: string | null; created_at: string };
|
||||
expect(d.origin_session_id).toBe('chat-1'); // the session that created it
|
||||
expect(d.created_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('each task gets its own isolated session, and list fans out across them', async () => {
|
||||
const a = await dispatch(
|
||||
{ id: 'r-a', command: 'tasks-create', args: { prompt: 'task A', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
const b = await dispatch(
|
||||
{ id: 'r-b', command: 'tasks-create', args: { prompt: 'task B', process_after: '2026-01-15T09:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(a.ok && b.ok).toBe(true);
|
||||
if (!a.ok || !b.ok) return;
|
||||
const ta = a.data as { session_id: string; series_id: string };
|
||||
const tb = b.data as { session_id: string; series_id: string };
|
||||
|
||||
// Distinct per-series sessions — not one shared system:tasks session.
|
||||
expect(ta.session_id).not.toBe(tb.session_id);
|
||||
|
||||
// list (no --session) fans out across every task session in the group.
|
||||
const list = await dispatch({ id: 'r-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const ids = (list.data as Array<{ series_id: string }>).map((t) => t.series_id);
|
||||
expect(ids).toContain(ta.series_id);
|
||||
expect(ids).toContain(tb.series_id);
|
||||
});
|
||||
|
||||
it('list enriches each series with run history (CronJob view)', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'r-agg',
|
||||
command: 'tasks-create',
|
||||
args: { prompt: 'brain digest', recurrence: '0 9 * * *', 'process-after': '2026-01-15T09:05:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { session_id, series_id } = created.data as { session_id: string; series_id: string };
|
||||
|
||||
// Seed three completed fires for this series into its own session inbound.db.
|
||||
const db = new Database(inboundDbPath('ag-1', session_id));
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id, process_after) ' +
|
||||
"VALUES (?, ?, datetime('now'), 'completed', 0, 'task', '{}', ?, ?)",
|
||||
);
|
||||
ins.run('run-1', 100, series_id, '2026-01-15T09:02:00Z');
|
||||
ins.run('run-2', 102, series_id, '2026-01-15T09:03:00Z');
|
||||
ins.run('run-3', 104, series_id, '2026-01-15T09:04:00Z');
|
||||
db.close();
|
||||
|
||||
const list = await dispatch({ id: 'r-agg-l', command: 'tasks-list', args: {} }, agentCtx('ag-1', 'chat-1'));
|
||||
expect(list.ok).toBe(true);
|
||||
if (!list.ok) return;
|
||||
const row = (list.data as Array<Record<string, unknown>>).find((t) => t.series_id === series_id);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.runs).toBe(3);
|
||||
expect(row?.last_run).toBe('2026-01-15T09:04:00Z'); // max completed process_after
|
||||
expect(String(row?.next_run)).toMatch(/^2026-01-15T09:05:00/); // the live pending occurrence
|
||||
expect(row?.schedule).toBe('0 9 * * *');
|
||||
expect(row?.log).toBe(`tasks/${series_id}.md`);
|
||||
});
|
||||
|
||||
// The schedule→wake primitive without a container: a task created through the
|
||||
// real `ncl tasks create` path must land in the agent group's system session
|
||||
// AND be counted by the same due-message query the host sweep uses to decide a
|
||||
// wake. Goes red if trigger defaulting, system-session routing, or the due
|
||||
// predicate ever drift apart.
|
||||
describe('a due task makes the system session wakeable', () => {
|
||||
it('countDueMessages sees a past task and ignores a future one', async () => {
|
||||
const created = await dispatch(
|
||||
{ id: 'r-due', command: 'tasks-create', args: { prompt: 'run me', 'process-after': '2020-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const systemId = (created.data as { session_id: string }).session_id;
|
||||
|
||||
const dueDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(dueDb)).toBe(1); // host sweep would wake this session
|
||||
dueDb.close();
|
||||
|
||||
// A far-future task in the same system session is not yet due.
|
||||
const future = await dispatch(
|
||||
{ id: 'r-fut', command: 'tasks-create', args: { prompt: 'later', 'process-after': '2999-01-01T00:00:00Z' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(future.ok).toBe(true);
|
||||
|
||||
const stillDb = new Database(inboundDbPath('ag-1', systemId), { readonly: true });
|
||||
expect(countDueMessages(stillDb)).toBe(1); // still just the one past task
|
||||
stillDb.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append-log', () => {
|
||||
const logFile = (folder: string, series: string) => `${TEST_DIR}/groups/${folder}/tasks/${series}.md`;
|
||||
|
||||
it('writes a host-timestamped line to the run log and creates the file (explicit --id)', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-1', command: 'tasks-append-log', args: { id: 'my-task-1', msg: 'did the thing; it worked' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim();
|
||||
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — did the thing; it worked$/);
|
||||
});
|
||||
|
||||
it('derives the series from the caller task session when --id is omitted', async () => {
|
||||
const created = await dispatch(
|
||||
{
|
||||
id: 'al-c',
|
||||
command: 'tasks-create',
|
||||
args: { name: 'derive-me', prompt: 'x', 'process-after': '2999-01-01T00:00:00Z' },
|
||||
},
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const { series_id, session_id } = created.data as { series_id: string; session_id: string };
|
||||
|
||||
// The fire runs INSIDE that task session, so no --id is needed.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-2', command: 'tasks-append-log', args: { msg: 'auto-derived run' } },
|
||||
agentCtx('ag-1', session_id),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) return;
|
||||
expect((resp.data as { series: string }).series).toBe(series_id);
|
||||
expect(fs.readFileSync(logFile('ag-1', series_id), 'utf8')).toContain('auto-derived run');
|
||||
});
|
||||
|
||||
it('requires --msg', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-3', command: 'tasks-append-log', args: { id: 'my-task-1' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toContain('--msg is required');
|
||||
});
|
||||
|
||||
it('errors when there is no --id and the caller is not in a task session', async () => {
|
||||
// chat-1 is a normal chat session, not system:tasks:* → nothing to derive.
|
||||
const resp = await dispatch(
|
||||
{ id: 'al-4', command: 'tasks-append-log', args: { msg: 'orphan' } },
|
||||
agentCtx('ag-1', 'chat-1'),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.message).toMatch(/--id is required/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTasksTable', () => {
|
||||
const now = Date.parse('2026-01-15T09:05:30Z');
|
||||
const rows = [
|
||||
{
|
||||
series_id: 'task-5bbe082a-6298-4699',
|
||||
schedule: '* * * * *',
|
||||
runs: 7,
|
||||
failed_runs: 2,
|
||||
last_run: '2026-01-15T09:04:30Z',
|
||||
next_run: '2026-01-15T09:06:00Z',
|
||||
status: 'pending',
|
||||
log: 'tasks/task-5bbe082a.md',
|
||||
created_at: '2026-01-15T08:05:30Z', // 1h before now
|
||||
prompt: 'You are NanoClaw, wired into the company brain, your job this run is to read it',
|
||||
},
|
||||
];
|
||||
|
||||
it('renders an aligned table with run history', () => {
|
||||
const lines = formatTasksTable(rows, now).split('\n');
|
||||
expect(lines[0]).toMatch(/SERIES\s+SCHEDULE\s+RUNS\s+FAILED\s+LAST RUN\s+NEXT RUN\s+STATUS\s+AGE\s+PROMPT/);
|
||||
expect(lines[1]).toContain('1h'); // AGE column — created 1h ago
|
||||
expect(lines[1]).toContain('task-5bbe082a-6298-4699'); // FULL series id — copy-pasteable into `tasks get --id`
|
||||
expect(lines[1]).toContain('* * * * *');
|
||||
expect(lines[1]).toContain('1m ago'); // 09:04:30 vs 09:05:30
|
||||
expect(lines[1]).toContain('in 30s'); // 09:06:00 vs 09:05:30
|
||||
expect(lines[1]).toContain('…'); // prompt truncated
|
||||
});
|
||||
|
||||
it('handles a never-fired series and an empty list', () => {
|
||||
expect(formatTasksTable([], now)).toBe('No tasks.');
|
||||
const oneShot = formatTasksTable(
|
||||
[
|
||||
{
|
||||
series_id: 'task-x',
|
||||
schedule: 'once',
|
||||
runs: 0,
|
||||
last_run: null,
|
||||
next_run: '2026-01-15T09:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
now,
|
||||
).split('\n')[1];
|
||||
expect(oneShot).toContain('once');
|
||||
expect(oneShot).toMatch(/\bdue\b/); // next_run in the past → due
|
||||
expect(oneShot).toContain('-'); // last_run '-' (never fired)
|
||||
});
|
||||
});
|
||||
|
||||
describe('deep verb help (ncl tasks help create)', () => {
|
||||
it('resolves through the dispatcher fallback and renders the full contract + examples', async () => {
|
||||
// Side-effect import mirrors the CLI server boot: registers <plural>-help.
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h1', command: 'tasks-help-create', args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const text = resp.data as string;
|
||||
expect(text).toContain('ncl tasks create');
|
||||
expect(text).toContain('wakeAgent'); // full multi-line script contract present
|
||||
expect(text).toContain('Examples:'); // examples block rendered
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown verb with a pointer back to resource help', async () => {
|
||||
await import('../commands/index.js');
|
||||
const resp = await dispatch({ id: 'h2', command: 'tasks-help-frobnicate', args: {} }, { caller: 'host' });
|
||||
expect(resp.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,784 +0,0 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import {
|
||||
findTaskSessions,
|
||||
getActiveSessions,
|
||||
getSession,
|
||||
isTaskThread,
|
||||
TASKS_SYSTEM_THREAD_ID,
|
||||
} from '../../db/sessions.js';
|
||||
import {
|
||||
cancelAllTasks,
|
||||
cancelTask,
|
||||
deleteTask,
|
||||
insertTaskRow,
|
||||
pauseTask,
|
||||
resumeTask,
|
||||
updateTask,
|
||||
type TaskUpdate,
|
||||
} from '../../modules/scheduling/db.js';
|
||||
import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js';
|
||||
import { parseZonedToUtc } from '../../timezone.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
import { formatTasksTable } from '../format-tasks.js';
|
||||
import type { CallerContext } from '../frame.js';
|
||||
|
||||
type TaskStatus = 'pending' | 'paused';
|
||||
|
||||
interface TaskRow {
|
||||
row_id: string;
|
||||
series_id: string | null;
|
||||
status: string;
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tries: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface ScopedSession {
|
||||
id: string;
|
||||
agent_group_id: string;
|
||||
}
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function bool(value: unknown): boolean {
|
||||
return value === true || value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Short, readable, filesystem/thread-safe task id. With a name → `<slug>-<4hex>`
|
||||
* (e.g. "Morning joke" → `morning-joke-a25c`); without → `t-<6hex>`. Always
|
||||
* matches /^[a-z0-9-]+$/ so it is safe as a thread suffix (`system:tasks:<id>`),
|
||||
* a filename (`tasks/<id>.md`), and a copy-pasteable --id.
|
||||
*/
|
||||
function makeTaskId(name: unknown): string {
|
||||
const hex = (n: number): string => randomUUID().replace(/-/g, '').slice(0, n);
|
||||
const slug =
|
||||
typeof name === 'string'
|
||||
? name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 24)
|
||||
.replace(/-+$/g, '')
|
||||
: '';
|
||||
return slug ? `${slug}-${hex(4)}` : `t-${hex(6)}`;
|
||||
}
|
||||
|
||||
function parseProcessAfter(value: unknown): string {
|
||||
const raw = str(value);
|
||||
if (!raw) throw new Error('--process-after is required');
|
||||
const date = parseZonedToUtc(raw, TIMEZONE);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`invalid --process-after: ${raw}`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run timestamp for a new task. When a recurrence is given but no
|
||||
* --process-after, derive the first fire from the cron grid (in TIMEZONE) so the
|
||||
* common recurring case is a single flag — `--recurrence "0 9 * * 1-5"` — with no
|
||||
* redundant, easily-stale hand-picked instant. --process-after is still required
|
||||
* for one-shots (no recurrence to derive from) and still wins when supplied.
|
||||
*/
|
||||
function firstRunIso(value: unknown, recurrence: string | null): string {
|
||||
if (str(value) === undefined && recurrence) {
|
||||
const next = CronExpressionParser.parse(recurrence, { tz: TIMEZONE }).next().toISOString();
|
||||
if (!next) throw new Error(`--recurrence has no upcoming run: ${recurrence}`);
|
||||
return next;
|
||||
}
|
||||
return parseProcessAfter(value);
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string') return String(value);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '' || trimmed === 'null' || trimmed === 'none') return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRecurrence(value: string | null | undefined): void {
|
||||
if (!value) return;
|
||||
try {
|
||||
CronExpressionParser.parse(value, { tz: TIMEZONE });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`invalid --recurrence: ${msg}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency guard: refuse recurrences more frequent than 4 fires/day unless
|
||||
* the agent explicitly overrides. Frequent tasks burn the user's quota (or
|
||||
* get their account banned) — the sanctioned pattern is a slower cron plus a
|
||||
* pre-task gate script that checks an external condition and only wakes the
|
||||
* agent when something changed (`ncl tasks create --help`). Counted over the
|
||||
* next 24h from now in the instance timezone, so uneven crons are judged by
|
||||
* what they would actually do.
|
||||
*/
|
||||
const MAX_DAILY_FIRES = 4;
|
||||
|
||||
const RECURRENCE_LIMIT_WARNING =
|
||||
'Warning: this task has not been scheduled. Frequent running tasks consume the ' +
|
||||
"user's subscription quota or unnecessarily use tokens and can cause the user's " +
|
||||
'account to be banned. Instead, use a pre-task run script that you write that can ' +
|
||||
'check some kind of external condition, usually via one or more API calls. The ' +
|
||||
'script returns a decision programmatically whether the task needs to be run now ' +
|
||||
'or not. For example, an API call to GitHub to check if there are open PRs, and ' +
|
||||
'only run when there are new open PRs.\n' +
|
||||
'Run `ncl tasks create --help` to get full directions on how to write a script and test it.\n\n' +
|
||||
'Note: if and only if you explicitly need to schedule a task more frequently and ' +
|
||||
"you've verified with the user that they understand and that this is what they " +
|
||||
'want and based on your judgment you agree that this is the right thing to do in ' +
|
||||
'this situation, you can override this with --dangerously-override-recurrence-limit';
|
||||
|
||||
function enforceRecurrenceLimit(recurrence: string | null, override: boolean, hasScript: boolean): void {
|
||||
// A gate script IS the sanctioned mitigation the warning steers toward — a
|
||||
// script-gated fire that finds nothing never wakes the agent, so scripted
|
||||
// tasks may run at any cadence without the override.
|
||||
if (!recurrence || override || hasScript) return;
|
||||
const horizon = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const interval = CronExpressionParser.parse(recurrence, { tz: TIMEZONE });
|
||||
let fires = 0;
|
||||
while (fires <= MAX_DAILY_FIRES) {
|
||||
const next = interval.next();
|
||||
if (next.getTime() > horizon) break;
|
||||
fires++;
|
||||
}
|
||||
if (fires > MAX_DAILY_FIRES) throw new Error(RECURRENCE_LIMIT_WARNING);
|
||||
}
|
||||
|
||||
function statusFilter(args: Record<string, unknown>): TaskStatus | undefined {
|
||||
const status = str(args.status);
|
||||
if (!status) return undefined;
|
||||
if (status !== 'pending' && status !== 'paused') {
|
||||
throw new Error('--status must be pending or paused');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function groupArg(args: Record<string, unknown>, ctx: CallerContext): string | undefined {
|
||||
if (ctx.caller === 'agent') return ctx.agentGroupId;
|
||||
return str(args.group) ?? str(args.agent_group_id);
|
||||
}
|
||||
|
||||
function ownSession(sessionId: string, ctx: CallerContext): ScopedSession {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) throw new Error(`session not found: ${sessionId}`);
|
||||
if (ctx.caller === 'agent' && session.agent_group_id !== ctx.agentGroupId) {
|
||||
throw new Error(`session not found: ${sessionId}`);
|
||||
}
|
||||
return { id: session.id, agent_group_id: session.agent_group_id };
|
||||
}
|
||||
|
||||
function selectedSessions(args: Record<string, unknown>, ctx: CallerContext): ScopedSession[] {
|
||||
const sessionId = str(args.session);
|
||||
if (sessionId) return [ownSession(sessionId, ctx)];
|
||||
|
||||
const group = groupArg(args, ctx);
|
||||
if (group) {
|
||||
// One session per live task series — the loops below already fan out across them.
|
||||
return findTaskSessions(group).map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
if (ctx.caller === 'agent') return [];
|
||||
return getActiveSessions().map((s) => ({ id: s.id, agent_group_id: s.agent_group_id }));
|
||||
}
|
||||
|
||||
function withInbound<T>(session: ScopedSession, fn: (db: Database.Database) => T): T | undefined {
|
||||
if (!fs.existsSync(inboundDbPath(session.agent_group_id, session.id))) return undefined;
|
||||
return withInboundDb(session.agent_group_id, session.id, fn);
|
||||
}
|
||||
|
||||
function parseContent(raw: string): { prompt: string; script: string | null; originSessionId: string | null } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
||||
script: typeof parsed.script === 'string' ? parsed.script : null,
|
||||
originSessionId: typeof parsed.originSessionId === 'string' ? parsed.originSessionId : null,
|
||||
};
|
||||
} catch {
|
||||
// LEGACY-COMPAT(v1-tasks): plain-string content from rows that predate the
|
||||
// JSON envelope. Removable once no pre-v2 session DBs remain in the wild.
|
||||
return { prompt: raw, script: null, originSessionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function toOutput(session: ScopedSession, row: TaskRow) {
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
agent_group_id: session.agent_group_id,
|
||||
session_id: session.id,
|
||||
series_id: row.series_id ?? row.row_id,
|
||||
row_id: row.row_id,
|
||||
status: row.status,
|
||||
process_after: row.process_after,
|
||||
recurrence: row.recurrence,
|
||||
prompt: content.prompt.length > 120 ? content.prompt.slice(0, 117) + '...' : content.prompt,
|
||||
has_script: content.script ? 1 : 0,
|
||||
origin_session_id: content.originSessionId, // which session created the task (null for CLI-created)
|
||||
created_at: row.timestamp,
|
||||
tries: row.tries,
|
||||
};
|
||||
}
|
||||
|
||||
function selectLiveTasks(db: Database.Database, status?: TaskStatus): TaskRow[] {
|
||||
const statusSql = status ? 'status = ?' : "status IN ('pending', 'paused')";
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, MAX(seq) AS seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND ${statusSql}
|
||||
GROUP BY series_id
|
||||
ORDER BY datetime(process_after) ASC, seq ASC`,
|
||||
)
|
||||
.all(...(status ? [status] : [])) as TaskRow[];
|
||||
}
|
||||
|
||||
function selectTask(db: Database.Database, id: string): TaskRow | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id AS row_id, series_id, status, process_after, recurrence, content, timestamp, tries, seq
|
||||
FROM messages_in
|
||||
WHERE kind = 'task'
|
||||
AND (id = ? OR series_id = ?)
|
||||
ORDER BY CASE WHEN status IN ('pending', 'paused') THEN 0 ELSE 1 END, seq DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(id, id) as TaskRow | undefined;
|
||||
}
|
||||
|
||||
function taskId(args: Record<string, unknown>): string {
|
||||
const id = str(args.id);
|
||||
if (!id) throw new Error('task series id is required');
|
||||
return id;
|
||||
}
|
||||
|
||||
function createTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const group = groupArg(args, ctx);
|
||||
if (!group) throw new Error('--group is required');
|
||||
const prompt = str(args.prompt);
|
||||
if (!prompt) throw new Error('--prompt is required');
|
||||
const recurrence = normalizeNullableString(args.recurrence) ?? null;
|
||||
validateRecurrence(recurrence);
|
||||
const script = normalizeNullableString(args.script) ?? null;
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), script != null);
|
||||
const processAfter = firstRunIso(args.process_after, recurrence);
|
||||
const id = makeTaskId(args.name);
|
||||
const originSessionId = ctx.caller === 'agent' ? ctx.sessionId : null;
|
||||
// Each series runs in its own isolated session; point the fire at its own log.
|
||||
const { session } = resolveTaskSession(group, id);
|
||||
const promptWithLog =
|
||||
`${prompt}\n\n` +
|
||||
`[A task serves the user two separate ways — do whichever the task above asks for, and ALWAYS the run log:\n` +
|
||||
`• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — <message to="name">…</message> or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` +
|
||||
`• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` +
|
||||
` ncl tasks append-log --msg "<what you did, and why it mattered>"\n` +
|
||||
` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` +
|
||||
`Need context from past runs? Read tasks/${id}.md first.]`;
|
||||
|
||||
const created = withInbound(session, (db) => {
|
||||
insertTaskRow(db, {
|
||||
id,
|
||||
seriesId: id,
|
||||
processAfter,
|
||||
recurrence,
|
||||
content: JSON.stringify({ prompt: promptWithLog, script, originSessionId }),
|
||||
});
|
||||
return selectTask(db, id);
|
||||
});
|
||||
if (!created) throw new Error('task system session inbound.db not found');
|
||||
return toOutput(session, created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one host-timestamped line to a task's run log
|
||||
* (`<GROUPS_DIR>/<folder>/tasks/<series>.md`). This is NOT a delivery — it writes
|
||||
* nothing to messages_out; it just records what happened so the agent (and human)
|
||||
* can see when and why each fire ran. Inside a task fire the series is derived from
|
||||
* the caller's own task session, so the agent supplies only --msg.
|
||||
*/
|
||||
function appendTaskLog(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
): { series: string; timestamp: string; path: string; ok: true } {
|
||||
const msg = str(args.msg);
|
||||
if (!msg) throw new Error('--msg is required');
|
||||
|
||||
let series = str(args.id);
|
||||
let group = groupArg(args, ctx);
|
||||
if (!series && ctx.caller === 'agent' && ctx.sessionId) {
|
||||
const sess = getSession(ctx.sessionId);
|
||||
if (sess && sess.thread_id && isTaskThread(sess.thread_id)) {
|
||||
series = sess.thread_id.slice(`${TASKS_SYSTEM_THREAD_ID}:`.length);
|
||||
group ??= sess.agent_group_id;
|
||||
}
|
||||
}
|
||||
if (!series) throw new Error('--id is required (no task session to derive it from)');
|
||||
// Charset guard is the security boundary here: blocks path traversal and keeps
|
||||
// the id safe as a filename / thread suffix. Group scope is already enforced by
|
||||
// groupArg (a cli_scope=group caller can only ever resolve its own folder), so a
|
||||
// foreign id at worst writes a stray log under the caller's OWN folder — no leak.
|
||||
if (!/^[a-z0-9-]+$/.test(series)) throw new Error(`invalid task id: ${series}`);
|
||||
if (!group) throw new Error('could not resolve the agent group');
|
||||
|
||||
const ag = getAgentGroup(group);
|
||||
if (!ag) throw new Error(`agent group not found: ${group}`);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const dir = `${GROUPS_DIR}/${ag.folder}/tasks`;
|
||||
const file = `${dir}/${series}.md`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(file, `${timestamp} — ${msg}\n`);
|
||||
return { series, timestamp, path: file, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run history for one task series, aggregated over its occurrence rows: number
|
||||
* of successful fires, the last fire time, and failed fires (a row reaches
|
||||
* `failed` after MAX_TRIES on a stuck claim). Cancelled occurrences are
|
||||
* `cancelled`, not `completed`, so they never inflate the run count.
|
||||
*/
|
||||
function seriesStats(
|
||||
db: Database.Database,
|
||||
seriesKey: string,
|
||||
): { runs: number; last_run: string | null; failed_runs: number } {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'completed') AS runs,
|
||||
MAX(process_after) FILTER (WHERE status = 'completed') AS last_run,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed_runs
|
||||
FROM messages_in
|
||||
WHERE kind = 'task' AND (id = ? OR series_id = ?)`,
|
||||
)
|
||||
.get(seriesKey, seriesKey) as { runs: number; last_run: string | null; failed_runs: number };
|
||||
}
|
||||
|
||||
/** Last ~10 lines of a series' run log (`tasks/<series>.md`), newest last. */
|
||||
function tailRunLog(agentGroupId: string, seriesKey: string, lines = 10): string[] {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return [];
|
||||
const file = `${GROUPS_DIR}/${ag.folder}/tasks/${seriesKey}.md`;
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return fs.readFileSync(file, 'utf8').trimEnd().split('\n').filter(Boolean).slice(-lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* A task series is CronJob-like: the live (pending/paused) row is the next run,
|
||||
* and the `completed` rows are its run history. Enrich each listed series with
|
||||
* that history — run count, failures, last fire, next fire, schedule, and a
|
||||
* pointer to the agent's own run log — so `tasks list` reads as a compact
|
||||
* run-history table.
|
||||
*/
|
||||
function enrichListRow(db: Database.Database, base: ReturnType<typeof toOutput>) {
|
||||
const seriesKey = base.series_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
return {
|
||||
...base,
|
||||
schedule: base.recurrence ?? 'once',
|
||||
runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
last_run: stats.last_run,
|
||||
next_run: base.process_after,
|
||||
log: `tasks/${seriesKey}.md`,
|
||||
};
|
||||
}
|
||||
|
||||
function listTasks(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const status = statusFilter(args);
|
||||
const rows = [];
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const sessionRows = withInbound(session, (db) =>
|
||||
selectLiveTasks(db, status).map((row) => enrichListRow(db, toOutput(session, row))),
|
||||
);
|
||||
if (sessionRows) rows.push(...sessionRows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getTask(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const found = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const stats = seriesStats(db, seriesKey);
|
||||
const content = parseContent(row.content);
|
||||
return {
|
||||
...toOutput(session, row),
|
||||
prompt: content.prompt,
|
||||
script: content.script,
|
||||
origin_session_id: content.originSessionId,
|
||||
completed_runs: stats.runs,
|
||||
failed_runs: stats.failed_runs,
|
||||
recent_log: tailRunLog(session.agent_group_id, seriesKey),
|
||||
};
|
||||
});
|
||||
if (found) return found;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
function mutateTask(
|
||||
args: Record<string, unknown>,
|
||||
ctx: CallerContext,
|
||||
fn: (db: Database.Database, id: string) => number,
|
||||
) {
|
||||
const id = taskId(args);
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => fn(db, id)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched };
|
||||
}
|
||||
|
||||
function updateTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
const update: TaskUpdate = {};
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (args.process_after !== undefined) update.processAfter = parseProcessAfter(args.process_after);
|
||||
const recurrence = normalizeNullableString(args.recurrence);
|
||||
const script = normalizeNullableString(args.script);
|
||||
if (recurrence !== undefined) {
|
||||
validateRecurrence(recurrence);
|
||||
// Effective script AFTER this update: the new value when provided
|
||||
// (including an explicit clear), else whatever the task already has.
|
||||
let scriptAfter: string | null = script !== undefined ? script : null;
|
||||
if (script === undefined) {
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const row = withInbound(session, (db) => selectTask(db, id));
|
||||
if (row) {
|
||||
scriptAfter = parseContent(row.content).script;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
enforceRecurrenceLimit(recurrence, bool(args.dangerously_override_recurrence_limit), scriptAfter != null);
|
||||
update.recurrence = recurrence;
|
||||
}
|
||||
if (script !== undefined) update.script = script;
|
||||
const fields = Object.keys(update);
|
||||
if (fields.length === 0) throw new Error('nothing to update');
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, (db) => updateTask(db, id, update)) ?? 0;
|
||||
}
|
||||
if (touched === 0) throw new Error(`no live task matched: ${id}`);
|
||||
return { series_id: id, touched, fields };
|
||||
}
|
||||
|
||||
function cancelTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
if (!bool(args.all)) {
|
||||
return mutateTask(args, ctx, cancelTask);
|
||||
}
|
||||
|
||||
let touched = 0;
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
touched += withInbound(session, cancelAllTasks) ?? 0;
|
||||
}
|
||||
return { cancelled: touched };
|
||||
}
|
||||
|
||||
/**
|
||||
* `ncl tasks run <id>` — fire a task on demand without disturbing its schedule.
|
||||
* Inserts a fresh pending occurrence (same series, content, no recurrence) due
|
||||
* now, which the next sweep delivers through the normal fire path. Unlike
|
||||
* `update --process-after now`, it neither consumes a one-shot nor force-advances
|
||||
* a recurring series' armed occurrence, so it is safe for testing a task.
|
||||
*/
|
||||
function runTaskCommand(args: Record<string, unknown>, ctx: CallerContext) {
|
||||
const id = taskId(args);
|
||||
for (const session of selectedSessions(args, ctx)) {
|
||||
const fired = withInbound(session, (db) => {
|
||||
const row = selectTask(db, id);
|
||||
if (!row) return undefined;
|
||||
const seriesKey = row.series_id ?? row.row_id;
|
||||
const rowId = makeTaskId(`${seriesKey}-run`);
|
||||
// recurrence=NULL is load-bearing: a run-now row must not be re-armed by
|
||||
// handleRecurrence into a phantom series.
|
||||
insertTaskRow(db, {
|
||||
id: rowId,
|
||||
seriesId: seriesKey,
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
content: row.content,
|
||||
});
|
||||
return { series_id: seriesKey, row_id: rowId, status: 'pending' };
|
||||
});
|
||||
if (fired) return fired;
|
||||
}
|
||||
throw new Error(`task not found: ${id}`);
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'task',
|
||||
plural: 'tasks',
|
||||
table: 'messages_in',
|
||||
description:
|
||||
'Scheduled task — prompt plus run time. Tasks run from the agent group system session and the agent chooses delivery destination at fire time.',
|
||||
idColumn: 'series_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'series_id', type: 'string', description: 'Stable task handle.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group that owns the task.' },
|
||||
{ name: 'session_id', type: 'string', description: 'System session that runs the task.' },
|
||||
{ name: 'status', type: 'string', description: 'Live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
// Not flagged required: with --recurrence the first run is derived from the
|
||||
// cron grid (firstRunIso). Required only for one-shots, enforced in the
|
||||
// create handler — so the generic col.required validator must stay off here.
|
||||
description:
|
||||
'Next run time (ISO 8601 or naive local). Required for one-shots; with --recurrence the first run is derived from the cron grid.',
|
||||
updatable: true,
|
||||
},
|
||||
{ name: 'recurrence', type: 'string', description: 'Optional cron expression.', updatable: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt.', required: true, updatable: true },
|
||||
{ name: 'script', type: 'string', description: 'Optional pre-task bash script.', updatable: true },
|
||||
],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'List live tasks with per-series run history (schedule, runs, failures, next fire).',
|
||||
args: [
|
||||
{ name: 'status', type: 'string', description: 'Filter by live state.', enum: ['pending', 'paused'] },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
{
|
||||
name: 'all',
|
||||
type: 'boolean',
|
||||
description: 'List across all groups (host default when no --group; accepted for explicitness).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => listTasks(args, ctx),
|
||||
// Server-rendered run-history table (frame `human` field) — the container
|
||||
// agent gets the same legible view as the host CLI without a Bun-side
|
||||
// formatter copy.
|
||||
formatHuman: (rows) => formatTasksTable(rows as Parameters<typeof formatTasksTable>[0]),
|
||||
},
|
||||
get: {
|
||||
access: 'open',
|
||||
description: 'Get a task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => getTask(args, ctx),
|
||||
},
|
||||
create: {
|
||||
access: 'open',
|
||||
description:
|
||||
`Create a scheduled task (recurring or one-shot) in the agent group system session.\n\n` +
|
||||
`Requires --prompt plus EITHER --recurrence (recurring; first run derived from the cron grid) OR --process-after (one-shot, ISO 8601 or naive local). Always pass --name for a readable id.\n\n` +
|
||||
`--script contract (pre-task gate, runs BEFORE the agent wakes):\n` +
|
||||
` bash, 30s timeout, 1MB output cap. Its LAST stdout line must be JSON:\n` +
|
||||
` {"wakeAgent": <bool>, "data": {...}}\n` +
|
||||
` wakeAgent=false marks the run handled without waking the agent (zero tokens);\n` +
|
||||
` wakeAgent=true wakes the agent with data attached to the prompt.\n` +
|
||||
` DO: print the JSON as the very last line, exit 0, keep data small (a summary, not a dump).\n` +
|
||||
` DON'T: print anything after the JSON, prompt for input, or rely on state from previous runs.\n` +
|
||||
` Always test with bash -c '<script>' before scheduling.\n` +
|
||||
` Persist state between fires under the group workspace (e.g. a last-seen id file).\n` +
|
||||
` Use good judgement on whether to share with the user the script (only if they are technical), a description of the script condition, or whether there's no need.\n\n` +
|
||||
`Frequency limit: recurrences more frequent than ${MAX_DAILY_FIRES} fires/day are refused unless the task\n` +
|
||||
`carries a --script gate (the script decides whether each fire needs you — a gated fire that\n` +
|
||||
`finds nothing costs zero tokens) or you pass --dangerously-override-recurrence-limit after\n` +
|
||||
`the user explicitly confirmed they want an ungated frequent task.\n\n` +
|
||||
`Failure backoff: a script that ERRORS repeatedly backs the series off (2,4,8,…60 min between fires; each errored fire counts as a failed run); after 8 consecutive failures the series is auto-paused with a note in its run log — fix the script, then \`ncl tasks resume <id>\`. A deliberate wakeAgent=false is a normal run and never backs off. \`ncl tasks get <id>\` shows failed_runs and the run log.`,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
description: 'Short descriptive name → readable task id (<slug>-<hex>). Without it, ids are t-<hex>.',
|
||||
},
|
||||
{ name: 'prompt', type: 'string', description: 'Task prompt the agent wakes to.', required: true },
|
||||
{
|
||||
name: 'recurrence',
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression (instance TZ). First run derives from the cron grid when --process-after is omitted.',
|
||||
},
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{
|
||||
name: 'process_after',
|
||||
type: 'string',
|
||||
description: 'First/next run time (ISO 8601 or naive local). Required for one-shots.',
|
||||
},
|
||||
{
|
||||
name: 'script',
|
||||
type: 'string',
|
||||
description: 'Pre-task gate script (bash) — see the --script contract above.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
`# Recurring — --recurrence alone is enough; the first run comes off the cron grid:\nncl tasks create --name "sales briefing" --prompt "Send the weekday sales briefing" --recurrence "0 9 * * 1-5"`,
|
||||
`# One-shot — --process-after required (UTC, offset, or naive-local in the instance TZ):\nncl tasks create --name "ping" --prompt "Remind me to call Dana" --process-after "tomorrow 18:00"`,
|
||||
`# Monitor — script gates the run; the agent wakes only when something matters:\nncl tasks create --name "alert watch" --recurrence "*/15 * * * *" \\\n --prompt "Investigate the alerts in the script data and notify me if serious" \\\n --script 'c=$(curl -sf https://example.com/api/alerts | jq length) || exit 0\necho "{\\"wakeAgent\\": $([ "$c" -gt 0 ] && echo true || echo false), \\"data\\": {\\"alerts\\": $c}}"'`,
|
||||
],
|
||||
handler: async (args, ctx) => createTask(args, ctx),
|
||||
},
|
||||
'append-log': {
|
||||
access: 'open',
|
||||
description:
|
||||
'Append a one-line run summary to a task run log (tasks/<id>.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.',
|
||||
examples: [
|
||||
`# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`,
|
||||
],
|
||||
args: [
|
||||
{
|
||||
name: 'msg',
|
||||
type: 'string',
|
||||
description:
|
||||
'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the UTC timestamp; this is logged, never sent to the user.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
description: 'Task series id. Auto-derived when called from inside a task fire; required otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
],
|
||||
handler: async (args, ctx) => appendTaskLog(args, ctx),
|
||||
},
|
||||
update: {
|
||||
access: 'open',
|
||||
description: 'Update a live task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{ name: 'prompt', type: 'string', description: 'Replace the task prompt.' },
|
||||
{ name: 'process_after', type: 'string', description: 'New next-run time (ISO 8601 or naive local).' },
|
||||
{ name: 'recurrence', type: 'string', description: 'New cron expression; "null"/"none" clears it (one-shot).' },
|
||||
{
|
||||
name: 'dangerously_override_recurrence_limit',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Schedule more than 4 fires/day anyway. Only after the user explicitly confirmed they understand the quota/token cost and you agree it is right.',
|
||||
},
|
||||
{ name: 'script', type: 'string', description: 'New pre-task script; "null"/"none" removes it.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => updateTaskCommand(args, ctx),
|
||||
},
|
||||
cancel: {
|
||||
access: 'open',
|
||||
description: 'Cancel a live task by series id, or use --all as a kill switch.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id (omit with --all).' },
|
||||
{ name: 'all', type: 'boolean', description: 'Cancel every live task in scope — kill switch.' },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => cancelTaskCommand(args, ctx),
|
||||
},
|
||||
run: {
|
||||
access: 'open',
|
||||
description:
|
||||
'Fire a task now without changing its schedule (queues an extra run due immediately). Safe for testing — unlike update --process-after now, it neither consumes a one-shot nor advances a recurring series.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => runTaskCommand(args, ctx),
|
||||
},
|
||||
pause: {
|
||||
access: 'open',
|
||||
description: 'Pause a pending task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, pauseTask),
|
||||
},
|
||||
resume: {
|
||||
access: 'open',
|
||||
description: 'Resume a paused task by series id.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, resumeTask),
|
||||
},
|
||||
delete: {
|
||||
access: 'open',
|
||||
description: 'Hard-delete a task series and its history.',
|
||||
args: [
|
||||
{ name: 'id', type: 'string', description: 'Task series id.', required: true },
|
||||
{
|
||||
name: 'group',
|
||||
type: 'string',
|
||||
description: 'Agent group id (host callers; auto-filled to your own group inside a container).',
|
||||
},
|
||||
{ name: 'session', type: 'string', description: 'Limit to one task session id.' },
|
||||
],
|
||||
handler: async (args, ctx) => mutateTask(args, ctx, deleteTask),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { ensureSchema, getInboundSourceSessionId, migrateMessagesInTable, syncProcessingAcks } from './session-db.js';
|
||||
import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js';
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-session-db-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
@@ -92,66 +92,3 @@ describe('migrateMessagesInTable', () => {
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProcessingAcks — script-skip counter', () => {
|
||||
function freshPair() {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
ensureSchema(DB_PATH, 'inbound');
|
||||
const outPath = path.join(TEST_DIR, 'outbound.db');
|
||||
ensureSchema(outPath, 'outbound');
|
||||
return { inDb: new Database(DB_PATH), outDb: new Database(outPath) };
|
||||
}
|
||||
|
||||
function seedTask(inDb: InstanceType<typeof Database>, id: string, content: Record<string, unknown>) {
|
||||
inDb
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, kind, content, series_id)
|
||||
VALUES (?, 2, datetime('now'), 'processing', 0, 'task', ?, ?)`,
|
||||
)
|
||||
.run(id, JSON.stringify(content), id);
|
||||
}
|
||||
|
||||
function ack(outDb: InstanceType<typeof Database>, id: string, status: string) {
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))",
|
||||
)
|
||||
.run(id, status);
|
||||
}
|
||||
|
||||
const status = (inDb: InstanceType<typeof Database>, id: string) =>
|
||||
(inDb.prepare('SELECT status FROM messages_in WHERE id = ?').get(id) as { status: string }).status;
|
||||
|
||||
it('script-skip:error ack lands the row as a FAILED run (streak-derivable history)', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'script-skip:error');
|
||||
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('failed');
|
||||
});
|
||||
|
||||
it('a settled row is terminal — a lingering ack cannot flip failed back to completed', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'script-skip:error');
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
ack(outDb, 't1', 'completed');
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('failed');
|
||||
});
|
||||
|
||||
it('plain completed ack completes the row as before', () => {
|
||||
const { inDb, outDb } = freshPair();
|
||||
seedTask(inDb, 't1', { prompt: 'p', script: 'x' });
|
||||
ack(outDb, 't1', 'completed');
|
||||
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
|
||||
expect(status(inDb, 't1')).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
+8
-20
@@ -168,25 +168,15 @@ export function getMessageForRetry(
|
||||
|
||||
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||
const completed = outDb
|
||||
.prepare(
|
||||
"SELECT message_id, status FROM processing_ack WHERE status IN ('completed', 'failed', 'script-skip:error')",
|
||||
)
|
||||
.all() as Array<{ message_id: string; status: string }>;
|
||||
.prepare("SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed')")
|
||||
.all() as Array<{ message_id: string }>;
|
||||
|
||||
if (completed.length === 0) return;
|
||||
|
||||
// `script-skip:error` (pre-task script crashed) lands as a FAILED run —
|
||||
// semantically true, and it lets recurrence derive the trailing failed
|
||||
// streak from the occurrence rows themselves (no stored counter).
|
||||
const completeStmt = inDb.prepare(
|
||||
"UPDATE messages_in SET status = 'completed' WHERE id = ? AND status NOT IN ('completed', 'failed')",
|
||||
);
|
||||
const failStmt = inDb.prepare(
|
||||
"UPDATE messages_in SET status = 'failed' WHERE id = ? AND status NOT IN ('completed', 'failed')",
|
||||
);
|
||||
const updateStmt = inDb.prepare("UPDATE messages_in SET status = 'completed' WHERE id = ? AND status != 'completed'");
|
||||
inDb.transaction(() => {
|
||||
for (const { message_id, status } of completed) {
|
||||
(status === 'script-skip:error' ? failStmt : completeStmt).run(message_id);
|
||||
for (const { message_id } of completed) {
|
||||
updateStmt.run(message_id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -304,11 +294,9 @@ export function migrateDeliveredTable(db: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
// LEGACY-COMPAT(v1-tasks): adds columns added to messages_in after the initial
|
||||
// v2 schema to pre-existing session DBs — this lazy, on-open migration IS the
|
||||
// upgrade path for old installs (there is no central migration for session
|
||||
// DBs). No-op on fresh installs where the columns are in the baseline schema.
|
||||
// Backfills existing rows so invariants hold (series_id = id).
|
||||
// Adds columns added to messages_in after the initial v2 schema to
|
||||
// pre-existing session DBs. No-op on fresh installs where the columns are
|
||||
// in the baseline schema. Backfills existing rows so invariants hold.
|
||||
export function migrateMessagesInTable(db: Database.Database): void {
|
||||
const cols = new Set(
|
||||
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
|
||||
|
||||
+1
-48
@@ -3,8 +3,6 @@ import { getDb, hasTable } from './connection.js';
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
export const TASKS_SYSTEM_THREAD_ID = 'system:tasks';
|
||||
|
||||
export function createSession(session: Session): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
@@ -57,14 +55,7 @@ export function findSessionForAgent(
|
||||
/** Find an active session scoped to an agent group (ignoring messaging group). */
|
||||
export function findSessionByAgentGroup(agentGroupId: string): Session | undefined {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND status = 'active'
|
||||
AND NOT (messaging_group_id IS NULL AND thread_id IS NOT NULL AND thread_id LIKE 'system:%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.prepare("SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1")
|
||||
.get(agentGroupId) as Session | undefined;
|
||||
}
|
||||
|
||||
@@ -72,44 +63,6 @@ export function getSessionsByAgentGroup(agentGroupId: string): Session[] {
|
||||
return getDb().prepare('SELECT * FROM sessions WHERE agent_group_id = ?').all(agentGroupId) as Session[];
|
||||
}
|
||||
|
||||
export function findSystemSession(agentGroupId: string, threadId: string): Session | undefined {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND messaging_group_id IS NULL
|
||||
AND thread_id = ?
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentGroupId, threadId) as Session | undefined;
|
||||
}
|
||||
|
||||
/** Per-task session thread id for a scheduled task series. */
|
||||
export function taskThreadId(seriesId: string): string {
|
||||
return `${TASKS_SYSTEM_THREAD_ID}:${seriesId}`;
|
||||
}
|
||||
|
||||
/** True for any task session thread — a per-series one or the legacy shared one. */
|
||||
export function isTaskThread(threadId: string | null): boolean {
|
||||
return threadId === TASKS_SYSTEM_THREAD_ID || (threadId?.startsWith(`${TASKS_SYSTEM_THREAD_ID}:`) ?? false);
|
||||
}
|
||||
|
||||
/** All active task sessions for a group — one per live series, plus any legacy shared one. */
|
||||
export function findTaskSessions(agentGroupId: string): Session[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT * FROM sessions
|
||||
WHERE agent_group_id = ?
|
||||
AND messaging_group_id IS NULL
|
||||
AND status = 'active'
|
||||
AND (thread_id = ? OR thread_id LIKE ?)
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
.all(agentGroupId, TASKS_SYSTEM_THREAD_ID, `${TASKS_SYSTEM_THREAD_ID}:%`) as Session[];
|
||||
}
|
||||
|
||||
export function getActiveSessions(): Session[] {
|
||||
return getDb().prepare("SELECT * FROM sessions WHERE status = 'active'").all() as Session[];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* `registerDeliveryAction` is the hook modules use to handle system-kind
|
||||
* outbound messages; `getDeliveryAction` is the read side that makes those
|
||||
* registrations behavior-testable. Goes red if either half of the registry
|
||||
* is removed or the two stop sharing the same map.
|
||||
* is removed or the two stop sharing the same map. Every registration now
|
||||
* carries a guard spec or an explicit unguarded(<reason>) declaration —
|
||||
* omission is a type error.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
@@ -16,11 +18,14 @@ vi.mock('./container-runner.js', () => ({
|
||||
}));
|
||||
|
||||
import { registerDeliveryAction, getDeliveryAction, type DeliveryActionHandler } from './delivery.js';
|
||||
import { defineGuardedAction, HOLD, unguarded } from './guard/index.js';
|
||||
|
||||
const testUnguarded = unguarded('test — registry mechanics only');
|
||||
|
||||
describe('delivery action registry', () => {
|
||||
it('getDeliveryAction returns the handler registerDeliveryAction registered', () => {
|
||||
const handler: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_registry_action', handler);
|
||||
registerDeliveryAction('test_registry_action', handler, testUnguarded);
|
||||
expect(getDeliveryAction('test_registry_action')).toBe(handler);
|
||||
});
|
||||
|
||||
@@ -31,8 +36,34 @@ describe('delivery action registry', () => {
|
||||
it('re-registering an action overwrites the previous handler', () => {
|
||||
const first: DeliveryActionHandler = async () => {};
|
||||
const second: DeliveryActionHandler = async () => {};
|
||||
registerDeliveryAction('test_overwrite_action', first);
|
||||
registerDeliveryAction('test_overwrite_action', second);
|
||||
registerDeliveryAction('test_overwrite_action', first, testUnguarded);
|
||||
registerDeliveryAction('test_overwrite_action', second, testUnguarded);
|
||||
expect(getDeliveryAction('test_overwrite_action')).toBe(second);
|
||||
});
|
||||
|
||||
it('refuses to replace a guard-wrapped action with an unguarded handler', () => {
|
||||
const guardAction = defineGuardedAction({
|
||||
action: 'test.guarded-overwrite',
|
||||
baseline: () => HOLD('t'),
|
||||
});
|
||||
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
|
||||
guardAction,
|
||||
requestHold: async () => {},
|
||||
});
|
||||
|
||||
// Disarming the guard by re-registering unguarded must throw — otherwise
|
||||
// the action's catalog entry would still exist while the live path runs
|
||||
// unguarded.
|
||||
expect(() => registerDeliveryAction('test_guarded_overwrite', async () => {}, testUnguarded)).toThrow(
|
||||
/disarm the guard/,
|
||||
);
|
||||
|
||||
// Re-registering WITH a spec stays allowed (a legitimate replacement
|
||||
// keeps the action guarded).
|
||||
registerDeliveryAction('test_guarded_overwrite', async () => {}, {
|
||||
guardAction,
|
||||
requestHold: async () => {},
|
||||
});
|
||||
expect(getDeliveryAction('test_guarded_overwrite')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
+121
-18
@@ -20,12 +20,13 @@ import {
|
||||
markDeliveryFailed,
|
||||
migrateDeliveredTable,
|
||||
} from './db/session-db.js';
|
||||
import { guard, isUnguarded, type GuardedAction, type Unguarded } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
import { normalizeOptions } from './channels/ask-question.js';
|
||||
import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js';
|
||||
import { pauseTypingRefreshAfterDelivery, setTypingAdapter } from './modules/typing/index.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import type { Session } from './types.js';
|
||||
import type { PendingApproval, Session } from './types.js';
|
||||
|
||||
const ACTIVE_POLL_MS = 1000;
|
||||
const SWEEP_POLL_MS = 60_000;
|
||||
@@ -254,7 +255,7 @@ async function deliverMessage(
|
||||
|
||||
const content = JSON.parse(msg.content);
|
||||
|
||||
// System actions — handle internally (cli_request, etc.)
|
||||
// System actions — handle internally (schedule_task, cancel_task, etc.)
|
||||
if (msg.kind === 'system') {
|
||||
await handleSystemAction(content, session, inDb);
|
||||
return;
|
||||
@@ -393,14 +394,19 @@ async function deliverMessage(
|
||||
* Delivery action registry.
|
||||
*
|
||||
* Modules register handlers for system-kind outbound message actions via
|
||||
* `registerDeliveryAction`. Core checks the registry first in
|
||||
* `handleSystemAction` and falls through to the inline switch when no
|
||||
* handler is registered. The switch will shrink as modules are extracted
|
||||
* (scheduling, approvals, agent-to-agent) and eventually only its default
|
||||
* branch remains.
|
||||
* `registerDeliveryAction`. Unknown actions log "Unknown system action".
|
||||
*
|
||||
* Default when no handler registered and the switch doesn't match: log
|
||||
* "Unknown system action" and return.
|
||||
* Privileged delivery actions (create_agent, install_packages,
|
||||
* add_mcp_server) register with a guard spec: every path to the handler body
|
||||
* — dispatch, approved replay, test lookup — goes through the guard consult
|
||||
* (allow / hold / deny), so there is no unguarded route to it. On approve,
|
||||
* the continuation re-enters the same entry carrying the approval row as its
|
||||
* grant (`reenterGuardedDeliveryAction`), so the structural baseline is
|
||||
* re-checked live. Plain actions (scheduling self-actions, the cli_request
|
||||
* bridge — its inner commands are guarded at dispatch) register with an
|
||||
* explicit `unguarded(<reason>)` declaration instead of a spec — omission is
|
||||
* not representable, so the decision to run unguarded is visible, and
|
||||
* justified, at the registration site.
|
||||
*/
|
||||
export type DeliveryActionHandler = (
|
||||
content: Record<string, unknown>,
|
||||
@@ -408,18 +414,115 @@ export type DeliveryActionHandler = (
|
||||
inDb: Database.Database,
|
||||
) => Promise<void>;
|
||||
|
||||
const actionHandlers = new Map<string, DeliveryActionHandler>();
|
||||
/** Handler shape for guard-wrapped actions — must not touch inDb (replays run without one). */
|
||||
export type GuardedDeliveryHandler = (content: Record<string, unknown>, session: Session) => Promise<void>;
|
||||
|
||||
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler): void {
|
||||
if (actionHandlers.has(action)) {
|
||||
log.warn('Delivery action handler overwritten', { action });
|
||||
}
|
||||
actionHandlers.set(action, handler);
|
||||
export interface DeliveryGuardSpec {
|
||||
/** Guard action consulted before the handler runs — the defined value, not a name. */
|
||||
guardAction: GuardedAction;
|
||||
/**
|
||||
* Domain validation that runs before the guard — malformed requests are
|
||||
* answered (notify) without ever creating a hold. Return false to stop.
|
||||
*/
|
||||
precheck?: (content: Record<string, unknown>, session: Session) => boolean | Promise<boolean>;
|
||||
/** Create the hold (the domain's requestApproval call — card text lives with the domain). */
|
||||
requestHold: (content: Record<string, unknown>, session: Session) => Promise<void>;
|
||||
/** Tell the requester about a deny. */
|
||||
onDeny?: (content: Record<string, unknown>, session: Session, reason: string) => void;
|
||||
}
|
||||
|
||||
/** Look up a registered delivery-action handler. Lets module registrations be behavior-tested. */
|
||||
type DeliveryEntry =
|
||||
| { guard: Unguarded; handler: DeliveryActionHandler }
|
||||
| { guard: DeliveryGuardSpec; handler: GuardedDeliveryHandler };
|
||||
|
||||
const deliveryActions = new Map<string, DeliveryEntry>();
|
||||
|
||||
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
|
||||
return isUnguarded(entry.guard);
|
||||
}
|
||||
|
||||
export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void;
|
||||
export function registerDeliveryAction(action: string, handler: GuardedDeliveryHandler, spec: DeliveryGuardSpec): void;
|
||||
export function registerDeliveryAction(
|
||||
action: string,
|
||||
handler: DeliveryActionHandler | GuardedDeliveryHandler,
|
||||
guardDecl: DeliveryGuardSpec | Unguarded,
|
||||
): void {
|
||||
const existing = deliveryActions.get(action);
|
||||
if (existing) {
|
||||
// Replacing a guard-wrapped action with an unguarded handler would
|
||||
// disarm the guard while its catalog entry still exists — refuse. A
|
||||
// skill that wants to extend a guarded action must compose at the
|
||||
// module's exported functions instead, or re-register with a guard spec
|
||||
// of its own.
|
||||
if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) {
|
||||
throw new Error(
|
||||
`delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`,
|
||||
);
|
||||
}
|
||||
log.warn('Delivery action handler overwritten', { action });
|
||||
}
|
||||
// The overloads pair each handler shape with its declaration; the merged
|
||||
// implementation signature erases that pairing, hence the one cast.
|
||||
deliveryActions.set(action, { guard: guardDecl, handler } as DeliveryEntry);
|
||||
}
|
||||
|
||||
async function runGuarded(
|
||||
action: string,
|
||||
entry: Extract<DeliveryEntry, { guard: DeliveryGuardSpec }>,
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
grant: PendingApproval | null,
|
||||
): Promise<void> {
|
||||
const spec = entry.guard;
|
||||
if (spec.precheck && !(await spec.precheck(content, session))) return;
|
||||
|
||||
const decision = guard(spec.guardAction, {
|
||||
actor: { kind: 'agent', agentGroupId: session.agent_group_id, sessionId: session.id },
|
||||
payload: content,
|
||||
grant,
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
log.warn('Delivery action denied by guard', { action, reason: decision.reason });
|
||||
spec.onDeny?.(content, session, decision.reason);
|
||||
return;
|
||||
}
|
||||
if (decision.effect === 'hold') {
|
||||
await spec.requestHold(content, session);
|
||||
return;
|
||||
}
|
||||
await entry.handler(content, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve continuation for a guard-wrapped delivery action: re-enter the
|
||||
* entry with the approval row as the grant. The guard treats the grant as
|
||||
* hold-satisfied but re-runs the structural baseline, so approve-then-revoke
|
||||
* does not execute. Domains register this as their approval handler in the
|
||||
* same line that registers the action.
|
||||
*/
|
||||
export function reenterGuardedDeliveryAction(action: string) {
|
||||
return async (ctx: { session: Session; payload: Record<string, unknown>; approval: PendingApproval }) => {
|
||||
const entry = deliveryActions.get(action);
|
||||
if (!entry || isUnguardedEntry(entry)) {
|
||||
log.warn('Approved replay for an action that is not guard-wrapped — dropping', { action });
|
||||
return;
|
||||
}
|
||||
await runGuarded(action, entry, ctx.payload, ctx.session, ctx.approval);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The invocable for a registered action — the raw handler for unguarded
|
||||
* entries, the guard-consulting path for guarded ones. Dispatch and tests
|
||||
* both come through here; there is no route around the guard.
|
||||
*/
|
||||
export function getDeliveryAction(action: string): DeliveryActionHandler | undefined {
|
||||
return actionHandlers.get(action);
|
||||
const entry = deliveryActions.get(action);
|
||||
if (!entry) return undefined;
|
||||
if (isUnguardedEntry(entry)) return entry.handler;
|
||||
return (content, session) => runGuarded(action, entry, content, session, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,7 +538,7 @@ async function handleSystemAction(
|
||||
const action = content.action as string;
|
||||
log.info('System action from agent', { sessionId: session.id, action });
|
||||
|
||||
const registered = actionHandlers.get(action);
|
||||
const registered = getDeliveryAction(action);
|
||||
if (registered) {
|
||||
await registered(content, session, inDb);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Boot-time guard sanity — the one cross-registry invariant left to check
|
||||
* after all import-time registrations have run.
|
||||
*
|
||||
* The old registry walk is gone: everything it detected is now
|
||||
* unconstructible at the API level.
|
||||
* - A consult site cannot name a missing catalog entry — guard() takes the
|
||||
* GuardedAction VALUE returned by defineGuardedAction, so a dropped
|
||||
* module-edge import or a typo'd action is a compile error (and a forged
|
||||
* value is denied at runtime), not a silent allow.
|
||||
* - A handler cannot register unguarded by omission — every registry
|
||||
* (delivery actions, response handlers, interceptors, CLI commands)
|
||||
* requires a guard spec or an explicit unguarded(<reason>) declaration
|
||||
* at the registration site.
|
||||
*
|
||||
* What remains is completeness ACROSS registries: a guarded action that
|
||||
* holds via `approvalAction` needs a registered approval handler, or an
|
||||
* approved card resolves into nothing — the hold has no continuation. That
|
||||
* pairing only exists once every module has loaded (catalog entries and
|
||||
* approval handlers register from different modules), so it stays a boot
|
||||
* check with the fail-closed posture: the host refuses to start, surfacing
|
||||
* the mis-composition at skill-install time instead of at the first
|
||||
* approved card.
|
||||
*/
|
||||
import { listGuardedActions } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
import { getApprovalHandler } from './modules/approvals/primitive.js';
|
||||
|
||||
/** Holding actions with no approve continuation. Empty = conformant. */
|
||||
export function grantContinuationGaps(): string[] {
|
||||
return listGuardedActions()
|
||||
.filter((spec) => spec.approvalAction && !getApprovalHandler(spec.approvalAction))
|
||||
.map(
|
||||
(spec) =>
|
||||
`guarded action "${spec.action}" holds via approval action "${spec.approvalAction}" ` +
|
||||
'but no approval handler is registered — an approved hold would have no continuation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot check: refuse to start when a holding action has no continuation.
|
||||
* Call after all import-time registrations (any point in main()).
|
||||
*/
|
||||
export function enforceGuardConformance(): void {
|
||||
const gaps = grantContinuationGaps();
|
||||
if (gaps.length === 0) return;
|
||||
|
||||
console.error(
|
||||
[
|
||||
'',
|
||||
'='.repeat(64),
|
||||
'NanoClaw stopped: guard conformance failure',
|
||||
'='.repeat(64),
|
||||
'A guarded action can hold for approval, but no approval handler is',
|
||||
'registered for its approval action — an admin could click Approve',
|
||||
'and nothing would execute. This usually means a module (or skill)',
|
||||
'defined a holding baseline without registering its continuation.',
|
||||
'',
|
||||
...gaps.map((g) => ` - ${g}`),
|
||||
'',
|
||||
'Register the approval handler (registerApprovalHandler) in the same',
|
||||
'module that defines the guarded action, or drop approvalAction from',
|
||||
'the definition if the action can never hold.',
|
||||
'='.repeat(64),
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
log.error('Guard conformance failure — refusing to start', { gaps });
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Guard conformance — the boot invariant, checked with the real registries.
|
||||
*
|
||||
* The old registry walk is gone: an unmapped consult or an undeclared
|
||||
* unguarded registration is now unconstructible — guard() takes the defined
|
||||
* GuardedAction value (a dropped module-edge import or typo'd name is a
|
||||
* compile error), and every registry requires a guard spec or an explicit
|
||||
* unguarded(<reason>) declaration. What's left to verify structurally is the
|
||||
* cross-registry pairing the compiler can't see: every holding action has a
|
||||
* registered approve continuation. The check runs here in CI and at every
|
||||
* boot (enforceGuardConformance refuses to start) — CI can't see
|
||||
* skill-installed registrations, the boot check can.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Production barrels — side-effect imports populate the real registries.
|
||||
import '../cli/commands/index.js';
|
||||
import '../modules/index.js';
|
||||
import '../cli/delivery-action.js';
|
||||
import '../cli/dispatch.js'; // registers the cli_command approval handler
|
||||
|
||||
import { commandGuard, listCommands } from '../cli/registry.js';
|
||||
import { grantContinuationGaps } from '../guard-conformance.js';
|
||||
import { getApprovalHandler } from '../modules/approvals/primitive.js';
|
||||
import { defineGuardedAction, listGuardedActions } from './guard-actions.js';
|
||||
import { HOLD } from './types.js';
|
||||
|
||||
describe('guard conformance', () => {
|
||||
it('the grant-continuation check (shared with the boot check) reports zero gaps', () => {
|
||||
expect(grantContinuationGaps()).toEqual([]);
|
||||
});
|
||||
|
||||
it('every holding action pairs with a registered approval handler', () => {
|
||||
const holding = listGuardedActions().filter((spec) => spec.approvalAction);
|
||||
expect(holding.length).toBeGreaterThan(0);
|
||||
|
||||
const dangling = holding.filter((spec) => !getApprovalHandler(spec.approvalAction as string));
|
||||
expect(dangling.map((s) => s.action)).toEqual([]);
|
||||
});
|
||||
|
||||
it('every mutating ncl command derives a guard that holds via cli_command', () => {
|
||||
const mutating = listCommands().filter((cmd) => cmd.access === 'approval');
|
||||
expect(mutating.length).toBeGreaterThan(0);
|
||||
|
||||
const wrong = mutating.filter((cmd) => commandGuard(cmd.name).approvalAction !== 'cli_command');
|
||||
expect(wrong.map((c) => c.name)).toEqual([]);
|
||||
});
|
||||
|
||||
it('the domain catalog entries are defined once the module barrels load', () => {
|
||||
const actions = new Set(listGuardedActions().map((s) => s.action));
|
||||
for (const expected of [
|
||||
'agents.create',
|
||||
'a2a.send',
|
||||
'self_mod.install_packages',
|
||||
'self_mod.add_mcp_server',
|
||||
'senders.admit',
|
||||
'channels.register',
|
||||
]) {
|
||||
expect(actions.has(expected), `catalog is missing "${expected}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('defining the same action twice throws — names are the catalog key', () => {
|
||||
defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') });
|
||||
expect(() => defineGuardedAction({ action: 'test.dup-define', baseline: () => HOLD('x') })).toThrow(
|
||||
/already defined/,
|
||||
);
|
||||
});
|
||||
|
||||
// KEEP LAST: defines a holding action with no continuation into the shared
|
||||
// per-worker catalog, so every gap check after this point sees it.
|
||||
it('the check names a holding action with no approve continuation (what boot refuses on)', () => {
|
||||
defineGuardedAction({
|
||||
action: 'test.dangling-hold',
|
||||
approvalAction: 'test_dangling_hold_approved',
|
||||
baseline: () => HOLD('always'),
|
||||
});
|
||||
|
||||
const gaps = grantContinuationGaps();
|
||||
expect(gaps).toHaveLength(1);
|
||||
expect(gaps[0]).toContain('test.dangling-hold');
|
||||
expect(gaps[0]).toContain('no approval handler');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The action catalog — the enforcement boundary.
|
||||
*
|
||||
* An action either is defined here (and every consult passes its decision)
|
||||
* or cannot be consulted at all: guard() takes the GuardedAction VALUE
|
||||
* returned by defineGuardedAction, so the wiring between a consult site and
|
||||
* its baseline is a symbol reference the compiler checks. A dropped
|
||||
* module-edge import or a typo'd action name is a build error, not a
|
||||
* runtime fail-open — there is no lookup that can miss.
|
||||
*
|
||||
* Definitions are still recorded by name so boot can enumerate them: the
|
||||
* grant-continuation check (src/guard-conformance.ts) pairs every holding
|
||||
* action with its registered approval handler, and duplicate names are
|
||||
* refused at definition time (grants match on the name).
|
||||
*/
|
||||
import type { GuardDecision, GuardInput } from './types.js';
|
||||
import type { PendingApproval } from '../types.js';
|
||||
|
||||
export interface GuardedActionSpec {
|
||||
/** Dotted action name, e.g. 'roles.grant', 'agents.create', 'a2a.send'. */
|
||||
action: string;
|
||||
/**
|
||||
* Today's structural checks for this action, verbatim — the only source of
|
||||
* allow. Runs on every consult, including approved replays (a grant
|
||||
* satisfies a hold, never a deny).
|
||||
*/
|
||||
baseline: (input: GuardInput) => GuardDecision;
|
||||
/**
|
||||
* The pending_approvals.action its holds resolve through — a grant is only
|
||||
* accepted when its row carries this action. Omit for actions that can
|
||||
* never be held (deny/allow-only baselines).
|
||||
*/
|
||||
approvalAction?: string;
|
||||
/**
|
||||
* Extra domain binding between a grant and the replayed input (e.g. the
|
||||
* a2a target must match the held message). Runs in addition to the
|
||||
* approvalAction + live-row checks.
|
||||
*/
|
||||
grantMatches?: (grant: PendingApproval, input: GuardInput) => boolean;
|
||||
}
|
||||
|
||||
declare const guardedActionBrand: unique symbol;
|
||||
/**
|
||||
* A defined guarded action — only defineGuardedAction can mint one. The
|
||||
* brand makes the type nominal: a hand-rolled { action, baseline } object
|
||||
* does not typecheck at a consult site, and fails the runtime check too.
|
||||
*/
|
||||
export type GuardedAction = Readonly<GuardedActionSpec> & { readonly [guardedActionBrand]: true };
|
||||
|
||||
const defined = new Map<string, GuardedAction>();
|
||||
const minted = new WeakSet<object>();
|
||||
|
||||
export function defineGuardedAction(spec: GuardedActionSpec): GuardedAction {
|
||||
if (defined.has(spec.action)) {
|
||||
throw new Error(`guarded action "${spec.action}" is already defined — action names are the catalog key`);
|
||||
}
|
||||
const def = Object.freeze({ ...spec }) as GuardedAction;
|
||||
minted.add(def);
|
||||
defined.set(spec.action, def);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime backstop for callers outside the type system (plain JS, casts):
|
||||
* only values minted by defineGuardedAction pass — guard() denies the rest.
|
||||
*/
|
||||
export function isGuardedAction(value: unknown): value is GuardedAction {
|
||||
return typeof value === 'object' && value !== null && minted.has(value);
|
||||
}
|
||||
|
||||
export function listGuardedActions(): GuardedAction[] {
|
||||
return [...defined.values()].sort((a, b) => a.action.localeCompare(b.action));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Guard decision-function unit tests: the baseline is the decision (allow /
|
||||
* hold / deny returned as-is), grant semantics (satisfies holds, never
|
||||
* denies; invalid → refuse), the runtime backstop against forged action
|
||||
* values, and the fail-closed posture on a throwing baseline.
|
||||
*
|
||||
* Uses synthetic actions defined per test — the catalog is per-worker module
|
||||
* state with no reset, so action names are unique.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { guard } from './guard.js';
|
||||
import { defineGuardedAction, type GuardedAction } from './guard-actions.js';
|
||||
import { ALLOW, DENY, HOLD, type GuardInput } from './types.js';
|
||||
|
||||
const mockGetPendingApproval = vi.fn();
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
|
||||
}));
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const AGENT = { kind: 'agent', agentGroupId: 'ag-1', sessionId: 'sess-1' } as const;
|
||||
|
||||
function input(extra: Partial<GuardInput> = {}): GuardInput {
|
||||
return { actor: AGENT, payload: {}, ...extra };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetPendingApproval.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('the baseline is the decision', () => {
|
||||
it('baseline allow → allow', () => {
|
||||
const action = defineGuardedAction({ action: 't.allow1', baseline: () => ALLOW('ok') });
|
||||
expect(guard(action, input()).effect).toBe('allow');
|
||||
});
|
||||
|
||||
it('baseline hold → hold, default approver chain', () => {
|
||||
const action = defineGuardedAction({ action: 't.hold1', baseline: () => HOLD('needs approval') });
|
||||
const d = guard(action, input());
|
||||
expect(d.effect).toBe('hold');
|
||||
if (d.effect === 'hold') {
|
||||
expect(d.reason).toBe('needs approval');
|
||||
expect(d.approverUserId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('baseline hold → hold, carrying a named approver', () => {
|
||||
const action = defineGuardedAction({ action: 't.hold2', baseline: () => HOLD('policy row', 'telegram:dana') });
|
||||
const d = guard(action, input());
|
||||
expect(d.effect).toBe('hold');
|
||||
if (d.effect === 'hold') expect(d.approverUserId).toBe('telegram:dana');
|
||||
});
|
||||
|
||||
it('baseline deny → deny, carrying the reason', () => {
|
||||
const action = defineGuardedAction({ action: 't.deny1', baseline: () => DENY('structurally unauthorized') });
|
||||
const d = guard(action, input());
|
||||
expect(d.effect).toBe('deny');
|
||||
if (d.effect === 'deny') expect(d.reason).toBe('structurally unauthorized');
|
||||
});
|
||||
|
||||
it('a forged action value (not from defineGuardedAction) is denied', () => {
|
||||
const forged = { action: 't.forged', baseline: () => ALLOW('never vetted') } as unknown as GuardedAction;
|
||||
const d = guard(forged, input());
|
||||
expect(d.effect).toBe('deny');
|
||||
if (d.effect === 'deny') expect(d.reason).toContain('undefined action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('grants', () => {
|
||||
const grantRow = (action: string) =>
|
||||
({ approval_id: 'appr-1', action, payload: '{}' }) as unknown as NonNullable<GuardInput['grant']>;
|
||||
|
||||
it('a valid live grant satisfies a hold', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g1',
|
||||
approvalAction: 'g1_approved',
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
const grant = grantRow('g1_approved');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('allow');
|
||||
});
|
||||
|
||||
it('a grant never satisfies a deny — the baseline is re-checked live', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g2',
|
||||
approvalAction: 'g2_approved',
|
||||
baseline: () => DENY('revoked since'),
|
||||
});
|
||||
const grant = grantRow('g2_approved');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
const d = guard(action, input({ grant }));
|
||||
expect(d.effect).toBe('deny');
|
||||
if (d.effect === 'deny') expect(d.reason).toBe('revoked since');
|
||||
});
|
||||
|
||||
it('a dead grant (row deleted) refuses instead of re-holding', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g3',
|
||||
approvalAction: 'g3_approved',
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
mockGetPendingApproval.mockReturnValue(undefined);
|
||||
const d = guard(action, input({ grant: grantRow('g3_approved') }));
|
||||
expect(d.effect).toBe('deny');
|
||||
});
|
||||
|
||||
it("a grant for a different action doesn't transfer", () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g4',
|
||||
approvalAction: 'g4_approved',
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
const grant = grantRow('other_action');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('deny');
|
||||
});
|
||||
|
||||
it('a domain grantMatches binding can refuse a payload mismatch', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g5',
|
||||
approvalAction: 'g5_approved',
|
||||
grantMatches: () => false,
|
||||
baseline: () => HOLD('b'),
|
||||
});
|
||||
const grant = grantRow('g5_approved');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('deny');
|
||||
});
|
||||
|
||||
it('a grant on an already-allowed action is a no-op', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.g6',
|
||||
approvalAction: 'g6_approved',
|
||||
baseline: () => ALLOW('ok'),
|
||||
});
|
||||
const grant = grantRow('g6_approved');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
expect(guard(action, input({ grant })).effect).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed posture', () => {
|
||||
it('a throwing baseline denies', () => {
|
||||
const action = defineGuardedAction({
|
||||
action: 't.f1',
|
||||
baseline: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
expect(guard(action, input()).effect).toBe('deny');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* guard() — the one decision function every privileged action consults.
|
||||
*
|
||||
* The decision is the action's structural baseline — today's code checks,
|
||||
* defined per action at the module edges. The consult site holds the
|
||||
* GuardedAction value itself (defineGuardedAction), so there is no name
|
||||
* lookup and no fail-open path for an unknown action: an unwired consult is
|
||||
* a compile error, and a value that didn't come from defineGuardedAction is
|
||||
* denied at runtime. Policy-as-data (tighten-only rule sources composing
|
||||
* with the baseline) is deliberately deferred to phase 3 of the
|
||||
* guarded-actions design, where the generalized rules table arrives with its
|
||||
* first operator-visible consumer; until then the one policy table
|
||||
* (agent_message_policies) is consulted inside the a2a.send baseline.
|
||||
*
|
||||
* Grants: an approved replay carries the verified approval row. A valid
|
||||
* grant (live pending row whose action matches the entry's approval action,
|
||||
* plus any domain binding) satisfies a hold — the human already decided —
|
||||
* but NEVER a deny: the baseline is re-checked live, so approve-then-revoke
|
||||
* no longer executes. A grant that is present but invalid fails closed to
|
||||
* deny (no second card).
|
||||
*
|
||||
* The guard itself fails closed: a throwing baseline denies.
|
||||
*/
|
||||
import { getPendingApproval } from '../db/sessions.js';
|
||||
import { log } from '../log.js';
|
||||
import { isGuardedAction, type GuardedAction } from './guard-actions.js';
|
||||
import { ALLOW, DENY, type GuardDecision, type GuardInput } from './types.js';
|
||||
|
||||
export function guard(action: GuardedAction, input: GuardInput): GuardDecision {
|
||||
if (!isGuardedAction(action)) {
|
||||
// JS-level backstop — the branded type already forbids this. A
|
||||
// hand-rolled object must not carry a baseline never vetted at
|
||||
// definition time.
|
||||
log.error('Guard consulted with an undefined action — failing closed', {
|
||||
action: (action as { action?: unknown } | null)?.action,
|
||||
});
|
||||
return DENY('guard consulted with an undefined action (failing closed)');
|
||||
}
|
||||
|
||||
let decision: GuardDecision;
|
||||
try {
|
||||
decision = action.baseline(input);
|
||||
} catch (err) {
|
||||
log.error('Guard evaluation threw — failing closed', { action: action.action, err });
|
||||
return DENY('guard failure (failing closed)');
|
||||
}
|
||||
|
||||
if (!input.grant || decision.effect !== 'hold') {
|
||||
// A grant never loosens a deny (the baseline re-check is live), and a
|
||||
// grant on an already-allowed action is a no-op.
|
||||
return decision;
|
||||
}
|
||||
|
||||
// An invalid grant on a replay is a refusal, not a fresh hold — approved
|
||||
// replays must execute exactly once.
|
||||
if (grantSatisfies(action, input)) {
|
||||
return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`);
|
||||
}
|
||||
return DENY('replay carried an invalid or mismatched grant');
|
||||
}
|
||||
|
||||
function grantSatisfies(action: GuardedAction, input: GuardInput): boolean {
|
||||
const grant = input.grant;
|
||||
if (!grant || !action.approvalAction) return false;
|
||||
if (grant.action !== action.approvalAction) return false;
|
||||
// The row must still be live — resolution deletes it, so a grant can only
|
||||
// execute once and a fabricated row object doesn't pass.
|
||||
const live = getPendingApproval(grant.approval_id);
|
||||
if (!live || live.action !== action.approvalAction) return false;
|
||||
if (action.grantMatches && !action.grantMatches(grant, input)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Guard — the privileged-action decision seam (guarded-actions phase 2).
|
||||
*
|
||||
* See the guarded-actions decisions doc on the team hub. One decision
|
||||
* function (guard.ts) and a definition-derived action catalog
|
||||
* (guard-actions.ts). Consults carry the GuardedAction value returned by
|
||||
* defineGuardedAction — never a name to look up — so mis-wiring is a build
|
||||
* error, not a runtime fail-open.
|
||||
* Domain-free leaf: domain baselines are defined at the domain modules' edges.
|
||||
*/
|
||||
export { guard } from './guard.js';
|
||||
export {
|
||||
defineGuardedAction,
|
||||
isGuardedAction,
|
||||
listGuardedActions,
|
||||
type GuardedAction,
|
||||
type GuardedActionSpec,
|
||||
} from './guard-actions.js';
|
||||
export {
|
||||
ALLOW,
|
||||
DENY,
|
||||
HOLD,
|
||||
isUnguarded,
|
||||
unguarded,
|
||||
type GuardActor,
|
||||
type GuardDecision,
|
||||
type GuardInput,
|
||||
type Unguarded,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Guard vocabulary — the decision seam every privileged action passes.
|
||||
*
|
||||
* The guard is a domain-free leaf: this module may import the DB read layer,
|
||||
* config, log, and shared types — never src/cli/* or src/modules/*. Domain
|
||||
* knowledge (what an action's structural baseline checks) arrives via
|
||||
* definition: domain modules call defineGuardedAction (guard-actions.ts) at
|
||||
* their module edges and pass the returned value to every consult and
|
||||
* registration site — the wiring is a symbol reference the compiler checks.
|
||||
*/
|
||||
import type { PendingApproval } from '../types.js';
|
||||
|
||||
/** Who is attempting the action. Mirrors the CLI CallerContext + click identities. */
|
||||
export type GuardActor =
|
||||
| { kind: 'host' }
|
||||
| { kind: 'agent'; agentGroupId: string; sessionId?: string }
|
||||
| { kind: 'human'; userId: string }
|
||||
| { kind: 'system' };
|
||||
|
||||
export interface GuardInput {
|
||||
actor: GuardActor;
|
||||
/** Domain resource reference, e.g. { from, to } for a2a.send. */
|
||||
resource?: Record<string, string>;
|
||||
/** Action arguments — what the card summarizes and rules may later match on. */
|
||||
payload: Record<string, unknown>;
|
||||
/**
|
||||
* Verified approval row carried by an approved replay. A valid grant
|
||||
* satisfies a hold (the human already decided) but never a deny — the
|
||||
* structural baseline is re-checked live on every replay.
|
||||
*/
|
||||
grant?: PendingApproval | null;
|
||||
}
|
||||
|
||||
const unguardedBrand = Symbol('unguarded');
|
||||
/**
|
||||
* A registration that deliberately carries no guard. Omission is not
|
||||
* representable — every registry requires either a guard spec or this
|
||||
* marker, so the decision to run unguarded is visible, and justified, in
|
||||
* the diff that registers the handler. The reason travels with the
|
||||
* registration; `grep "unguarded("` is the complete inventory.
|
||||
*/
|
||||
export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true };
|
||||
|
||||
export function unguarded(reason: string): Unguarded {
|
||||
return Object.freeze({ reason, [unguardedBrand]: true as const });
|
||||
}
|
||||
|
||||
/**
|
||||
* The one runtime discriminator for guard declarations. The brand symbol is
|
||||
* module-private, so `unguarded()` is the only mint — a look-alike
|
||||
* `{ reason }` object (or a guard spec that someday grows a `reason` field)
|
||||
* doesn't pass.
|
||||
*/
|
||||
export function isUnguarded(decl: object): decl is Unguarded {
|
||||
return unguardedBrand in decl;
|
||||
}
|
||||
|
||||
export type GuardDecision =
|
||||
| { effect: 'allow'; reason: string }
|
||||
| { effect: 'hold'; reason: string; approverUserId?: string }
|
||||
| { effect: 'deny'; reason: string };
|
||||
|
||||
export const ALLOW = (reason: string): GuardDecision => ({ effect: 'allow', reason });
|
||||
export const DENY = (reason: string): GuardDecision => ({ effect: 'deny', reason });
|
||||
/**
|
||||
* approverUserId names an exclusive approver for the hold (the a2a policy
|
||||
* row's named approver). Absent, the hold goes to the approvals primitive's
|
||||
* default chain (scoped admins → global admins → owners).
|
||||
*/
|
||||
export const HOLD = (reason: string, approverUserId?: string): GuardDecision => ({
|
||||
effect: 'hold',
|
||||
reason,
|
||||
approverUserId,
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
_resetStuckProcessingRowsForTesting,
|
||||
decideStuckAction,
|
||||
parseSqliteUtc,
|
||||
shouldCloseTaskSession,
|
||||
} from './host-sweep.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
@@ -335,22 +334,3 @@ describe('parseSqliteUtc', () => {
|
||||
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldCloseTaskSession', () => {
|
||||
it('closes a spent per-task session (no live tasks, no container)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', false, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps it while a task is still live (recurring re-armed, or pending/paused)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', false, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps it while its container is running (mid-fire)', () => {
|
||||
expect(shouldCloseTaskSession('system:tasks:task-1', true, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('never touches non-task sessions', () => {
|
||||
expect(shouldCloseTaskSession('telegram:12345', false, 0)).toBe(false);
|
||||
expect(shouldCloseTaskSession(null, false, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-28
@@ -30,7 +30,7 @@ import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
|
||||
import { ensureEgressNetwork } from './egress-lockdown.js';
|
||||
import { getActiveSessions, isTaskThread, updateSession } from './db/sessions.js';
|
||||
import { getActiveSessions } from './db/sessions.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import {
|
||||
countDueMessages,
|
||||
@@ -167,15 +167,6 @@ async function sweep(): Promise<void> {
|
||||
setTimeout(sweep, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** A per-task session with no live tasks and no running container is spent → close it. */
|
||||
export function shouldCloseTaskSession(
|
||||
threadId: string | null,
|
||||
containerRunning: boolean,
|
||||
liveTaskCount: number,
|
||||
): boolean {
|
||||
return isTaskThread(threadId) && !containerRunning && liveTaskCount === 0;
|
||||
}
|
||||
|
||||
async function sweepSession(session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) return;
|
||||
@@ -243,24 +234,6 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
const { handleRecurrence } = await import('./modules/scheduling/recurrence.js');
|
||||
await handleRecurrence(inDb, session);
|
||||
// MODULE-HOOK:scheduling-recurrence:end
|
||||
|
||||
// 6. GC spent task sessions. An isolated per-task session with no live task
|
||||
// rows left (one-shot fired, or all cancelled/deleted) and no container
|
||||
// running is dead — close it so it stops being swept and listed. Runs after
|
||||
// recurrence so a just-fired recurring series has already re-armed its next
|
||||
// pending row and is never collected. The per-task log file in the workspace
|
||||
// is the durable history and survives the close.
|
||||
if (isTaskThread(session.thread_id)) {
|
||||
const liveTasks = (
|
||||
inDb
|
||||
.prepare("SELECT COUNT(*) AS c FROM messages_in WHERE kind = 'task' AND status IN ('pending', 'paused')")
|
||||
.get() as { c: number }
|
||||
).c;
|
||||
if (shouldCloseTaskSession(session.thread_id, isContainerRunning(session.id), liveTasks)) {
|
||||
updateSession(session.id, { status: 'closed' });
|
||||
log.info('Closed spent task session', { sessionId: session.id, threadId: session.thread_id });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inDb.close();
|
||||
outDb?.close();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { initDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
|
||||
import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
|
||||
import { enforceGuardConformance } from './guard-conformance.js';
|
||||
import { startHostSweep, stopHostSweep } from './host-sweep.js';
|
||||
import { routeInbound } from './router.js';
|
||||
import { log } from './log.js';
|
||||
@@ -69,6 +70,12 @@ async function main(): Promise<void> {
|
||||
// outside the sanctioned path (raw `git pull` instead of /update-nanoclaw).
|
||||
enforceUpgradeTripwire();
|
||||
|
||||
// 0.6 Guard conformance — every import-time registration has already run;
|
||||
// refuse to start if any privileged command / delivery action is unmapped
|
||||
// (CI can't see skill-installed code — this makes the invariant hold at
|
||||
// the boundary where third-party registrations enter).
|
||||
enforceGuardConformance();
|
||||
|
||||
// 1. Init central DB
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
|
||||
@@ -27,14 +27,15 @@ import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { guard } from '../../guard/index.js';
|
||||
import { log } from '../../log.js';
|
||||
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy } from './db/agent-message-policies.js';
|
||||
import { A2A_MESSAGE_GATE_ACTION, a2aSend } from './guard.js';
|
||||
|
||||
export { isSafeAttachmentName };
|
||||
export { A2A_MESSAGE_GATE_ACTION } from './guard.js';
|
||||
|
||||
export interface ForwardedAttachment {
|
||||
name: string;
|
||||
@@ -230,56 +231,64 @@ function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session,
|
||||
return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session;
|
||||
}
|
||||
|
||||
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
|
||||
export async function routeAgentMessage(
|
||||
msg: RoutableAgentMessage,
|
||||
session: Session,
|
||||
opts: { grant?: PendingApproval } = {},
|
||||
): Promise<void> {
|
||||
const sourceAgentGroupId = session.agent_group_id;
|
||||
const targetAgentGroupId = msg.platform_id;
|
||||
if (!targetAgentGroupId) {
|
||||
throw new Error(`agent-to-agent message ${msg.id} is missing a target agent group id`);
|
||||
}
|
||||
const isSelf = targetAgentGroupId === sourceAgentGroupId;
|
||||
if (!isSelf && !hasDestination(sourceAgentGroupId, 'agent', targetAgentGroupId)) {
|
||||
throw new Error(`unauthorized agent-to-agent: ${sourceAgentGroupId} has no destination for ${targetAgentGroupId}`);
|
||||
}
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
|
||||
|
||||
// The a2a.send baseline (guard.ts) carries the checks verbatim in their
|
||||
// original order: destination ACL deny, target-exists deny, self-send
|
||||
// allow, agent_message_policies hold. An approved replay carries the
|
||||
// grant — the hold is satisfied but the structure is re-checked live, so
|
||||
// revoking a destination between hold and approve blocks delivery.
|
||||
const decision = guard(a2aSend, {
|
||||
actor: { kind: 'agent', agentGroupId: sourceAgentGroupId, sessionId: session.id },
|
||||
resource: { from: sourceAgentGroupId, to: targetAgentGroupId },
|
||||
payload: { id: msg.id, platform_id: targetAgentGroupId, content: msg.content, in_reply_to: msg.in_reply_to },
|
||||
grant: opts.grant ?? null,
|
||||
});
|
||||
|
||||
if (decision.effect === 'deny') {
|
||||
throw new Error(decision.reason);
|
||||
}
|
||||
|
||||
// Gated edge: hold the message and return (not throw) so the delivery loop
|
||||
// consumes the outbound row; `applyA2aMessageGate` re-routes it on approve.
|
||||
if (!isSelf) {
|
||||
const policy = getMessagePolicy(sourceAgentGroupId, targetAgentGroupId);
|
||||
if (policy) {
|
||||
const { approver } = policy;
|
||||
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
|
||||
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceName,
|
||||
action: A2A_MESSAGE_GATE_ACTION,
|
||||
approverUserId: approver,
|
||||
title: 'Message approval',
|
||||
question: buildGateQuestion(sourceName, targetName, msg.content),
|
||||
payload: {
|
||||
id: msg.id,
|
||||
platform_id: targetAgentGroupId,
|
||||
content: msg.content,
|
||||
in_reply_to: msg.in_reply_to,
|
||||
},
|
||||
});
|
||||
log.info('Agent message held for approval', {
|
||||
from: sourceAgentGroupId,
|
||||
to: targetAgentGroupId,
|
||||
msgId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// consumes the outbound row; `applyA2aMessageGate` re-enters here with the
|
||||
// grant on approve.
|
||||
if (decision.effect === 'hold') {
|
||||
const sourceName = getAgentGroup(sourceAgentGroupId)?.name ?? sourceAgentGroupId;
|
||||
const targetName = getAgentGroup(targetAgentGroupId)?.name ?? targetAgentGroupId;
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: sourceName,
|
||||
action: A2A_MESSAGE_GATE_ACTION,
|
||||
approverUserId: decision.approverUserId,
|
||||
title: 'Message approval',
|
||||
question: buildGateQuestion(sourceName, targetName, msg.content),
|
||||
payload: {
|
||||
id: msg.id,
|
||||
platform_id: targetAgentGroupId,
|
||||
content: msg.content,
|
||||
in_reply_to: msg.in_reply_to,
|
||||
},
|
||||
});
|
||||
log.info('Agent message held for approval', {
|
||||
from: sourceAgentGroupId,
|
||||
to: targetAgentGroupId,
|
||||
msgId: msg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performAgentRoute(msg, session, targetAgentGroupId);
|
||||
}
|
||||
|
||||
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
|
||||
|
||||
const GATE_CARD_BODY_MAX = 1500;
|
||||
|
||||
function parseMessageContent(contentStr: string): { text: string; files: string[] } {
|
||||
@@ -308,9 +317,11 @@ function buildGateQuestion(sourceName: string, targetName: string, contentStr: s
|
||||
|
||||
/**
|
||||
* Cross-session route: pick the target session, forward files, write to its
|
||||
* inbound DB, wake it. Authorization is the caller's responsibility.
|
||||
* inbound DB, wake it. Module-private — the only door is routeAgentMessage's
|
||||
* guard decision (the approve continuation re-enters with a grant rather
|
||||
* than calling this directly).
|
||||
*/
|
||||
export async function performAgentRoute(
|
||||
async function performAgentRoute(
|
||||
msg: RoutableAgentMessage,
|
||||
session: Session,
|
||||
targetAgentGroupId: string,
|
||||
|
||||
@@ -2,26 +2,48 @@
|
||||
* Tests for create_agent host-side authorization.
|
||||
*
|
||||
* Regression guard for the audit finding: `create_agent` is a privileged
|
||||
* central-DB write with no host-side authz. The fix authorizes by CLI scope —
|
||||
* trusted owner agent groups ('global') create directly; confined groups
|
||||
* ('group', the default and the prompt-injection victim) must get admin
|
||||
* approval. These tests pin that branch decision.
|
||||
* central-DB write with no host-side authz. Authorization is the guard's
|
||||
* `agents.create` baseline — trusted owner agent groups ('global') create
|
||||
* directly; confined groups ('group', the default and the prompt-injection
|
||||
* victim) hold for admin approval. These tests drive the REAL wrapped
|
||||
* delivery action (the only reachable path) and the approve continuation's
|
||||
* grant-carrying re-entry.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Session } from '../../types.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
|
||||
// Mocks for the collaborators the branch decides between / depends on.
|
||||
const mockRequestApproval = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
const mockCreateAgentGroup = vi.fn();
|
||||
const mockInitGroupFilesystem = vi.fn();
|
||||
const mockUpdateScalars = vi.fn();
|
||||
const mockWriteDestinations = vi.fn();
|
||||
const mockNotifyWrite = vi.fn();
|
||||
// vi.hoisted: the module barrel import below runs before this file's const
|
||||
// initializers, and the mock factories close over this state.
|
||||
const {
|
||||
mockRequestApproval,
|
||||
mockGetContainerConfig,
|
||||
mockCreateAgentGroup,
|
||||
mockInitGroupFilesystem,
|
||||
mockUpdateScalars,
|
||||
mockWriteDestinations,
|
||||
mockNotifyWrite,
|
||||
liveApprovals,
|
||||
approvalHandlers,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRequestApproval: vi.fn().mockResolvedValue(undefined),
|
||||
mockGetContainerConfig: vi.fn(),
|
||||
mockCreateAgentGroup: vi.fn(),
|
||||
mockInitGroupFilesystem: vi.fn(),
|
||||
mockUpdateScalars: vi.fn(),
|
||||
mockWriteDestinations: vi.fn(),
|
||||
mockNotifyWrite: vi.fn(),
|
||||
liveApprovals: new Map<string, import('../../types.js').PendingApproval>(),
|
||||
approvalHandlers: new Map<string, (ctx: Record<string, unknown>) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock('../approvals/index.js', () => ({
|
||||
requestApproval: (...a: unknown[]) => mockRequestApproval(...a),
|
||||
notifyAgent: vi.fn(),
|
||||
registerApprovalHandler: (action: string, handler: (ctx: Record<string, unknown>) => Promise<void>) => {
|
||||
approvalHandlers.set(action, handler);
|
||||
},
|
||||
}));
|
||||
vi.mock('../../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...a: unknown[]) => mockGetContainerConfig(...a),
|
||||
@@ -42,36 +64,81 @@ vi.mock('./write-destinations.js', () => ({
|
||||
vi.mock('./db/agent-destinations.js', () => ({
|
||||
getDestinationByName: () => undefined,
|
||||
createDestination: vi.fn(),
|
||||
hasDestination: () => true,
|
||||
normalizeName: (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
}));
|
||||
// notifyAgent writes to the session inbound.db + wakes the container; stub both.
|
||||
// delivery.ts and agent-route.ts pull more session-manager exports at import time.
|
||||
vi.mock('../../session-manager.js', () => ({
|
||||
writeSessionMessage: (...a: unknown[]) => mockNotifyWrite(...a),
|
||||
openInboundDb: vi.fn(),
|
||||
openOutboundDb: vi.fn(),
|
||||
clearOutbox: vi.fn(),
|
||||
readOutboxFiles: vi.fn().mockReturnValue([]),
|
||||
resolveSession: vi.fn(),
|
||||
sessionDir: vi.fn().mockReturnValue('/tmp/nowhere'),
|
||||
inboundDbPath: vi.fn().mockReturnValue('/tmp/nowhere/inbound.db'),
|
||||
}));
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../../db/sessions.js', () => ({
|
||||
getSession: (id: string) => ({ id, agent_group_id: 'ag-1' }),
|
||||
getPendingApproval: (id: string) => liveApprovals.get(id),
|
||||
getRunningSessions: () => [],
|
||||
getActiveSessions: () => [],
|
||||
createPendingQuestion: vi.fn(),
|
||||
}));
|
||||
|
||||
import { handleCreateAgent } from './create-agent.js';
|
||||
// The a2a module barrel registers ./guard.js (catalog entries) and the
|
||||
// guard-wrapped create_agent delivery action — the path under test.
|
||||
import './index.js';
|
||||
import { getDeliveryAction } from '../../delivery.js';
|
||||
|
||||
const SESSION = { id: 'sess-1', agent_group_id: 'ag-1' } as Session;
|
||||
|
||||
async function runCreateAgent(content: Record<string, unknown>): Promise<void> {
|
||||
const wrapped = getDeliveryAction('create_agent');
|
||||
expect(wrapped).toBeDefined();
|
||||
await wrapped!(content, SESSION, undefined as never);
|
||||
}
|
||||
|
||||
function liveGrant(approvalId: string, payload: Record<string, unknown>): PendingApproval {
|
||||
const row = {
|
||||
approval_id: approvalId,
|
||||
session_id: SESSION.id,
|
||||
request_id: approvalId,
|
||||
action: 'create_agent',
|
||||
payload: JSON.stringify(payload),
|
||||
created_at: new Date().toISOString(),
|
||||
agent_group_id: 'ag-1',
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
platform_message_id: null,
|
||||
expires_at: null,
|
||||
status: 'pending',
|
||||
title: '',
|
||||
options_json: '[]',
|
||||
approver_user_id: null,
|
||||
} as PendingApproval;
|
||||
liveApprovals.set(approvalId, row);
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
liveApprovals.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('handleCreateAgent — scope-based authorization', () => {
|
||||
describe('create_agent — guard-based authorization (wrapped delivery action)', () => {
|
||||
it('global scope: creates directly, no approval requested', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout', instructions: 'help' });
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
|
||||
@@ -84,7 +151,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
// dropping the inheritance leaves the child provider-less (→ claude).
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global', provider: 'codex' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout', instructions: 'help' });
|
||||
|
||||
expect(mockInitGroupFilesystem).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -96,7 +163,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('claude creator leaves the child provider unset (built-in default)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' }); // no provider
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout', instructions: 'help' });
|
||||
|
||||
expect(mockUpdateScalars).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -104,7 +171,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('group scope (default): requires approval, does NOT create directly', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout', instructions: 'help' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout', instructions: 'help' });
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestApproval.mock.calls[0][0]).toMatchObject({ action: 'create_agent' });
|
||||
@@ -115,7 +182,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('missing config: fails closed to approval (no direct create)', async () => {
|
||||
mockGetContainerConfig.mockReturnValue(undefined);
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout' });
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
@@ -124,7 +191,7 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('disabled/other scope: requires approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||
|
||||
await handleCreateAgent({ name: 'Scout' }, SESSION);
|
||||
await runCreateAgent({ name: 'Scout' });
|
||||
|
||||
expect(mockRequestApproval).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
@@ -133,9 +200,58 @@ describe('handleCreateAgent — scope-based authorization', () => {
|
||||
it('empty name: neither creates nor requests approval', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||
|
||||
await handleCreateAgent({ name: '' }, SESSION);
|
||||
await runCreateAgent({ name: '' });
|
||||
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create_agent — approved replay (grant-carrying re-entry)', () => {
|
||||
it('valid grant executes exactly once — baseline hold is satisfied, create runs', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
const payload = { name: 'Scout', instructions: 'help' };
|
||||
const approval = liveGrant('appr-ca-1', payload);
|
||||
|
||||
const continuation = approvalHandlers.get('create_agent');
|
||||
expect(continuation).toBeDefined();
|
||||
await continuation!({ session: SESSION, payload, approval, userId: 'telegram:admin', notify: vi.fn() });
|
||||
|
||||
expect(mockCreateAgentGroup).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled(); // no second card
|
||||
});
|
||||
|
||||
it('dead grant (row already resolved) refuses the replay', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
const payload = { name: 'Scout', instructions: 'help' };
|
||||
const approval = liveGrant('appr-ca-2', payload);
|
||||
liveApprovals.delete('appr-ca-2'); // resolution consumed the row
|
||||
|
||||
await approvalHandlers.get('create_agent')!({
|
||||
session: SESSION,
|
||||
payload,
|
||||
approval,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled(); // refused, not re-held
|
||||
});
|
||||
|
||||
it('mismatched grant (approved for a different name) refuses the replay', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
const approval = liveGrant('appr-ca-3', { name: 'OtherAgent' });
|
||||
|
||||
await approvalHandlers.get('create_agent')!({
|
||||
session: SESSION,
|
||||
payload: { name: 'Scout' },
|
||||
approval,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
expect(mockCreateAgentGroup).not.toHaveBeenCalled();
|
||||
expect(mockRequestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* `create_agent` delivery-action handler.
|
||||
* `create_agent` delivery-action bodies.
|
||||
*
|
||||
* SECURITY: `create_agent` writes to the CENTRAL DB (agent_groups,
|
||||
* container_configs, agent_destinations) and scaffolds host filesystem state —
|
||||
* a privileged operation a confined container is otherwise architecturally
|
||||
* barred from. The container's MCP tool gate is inside the (untrusted)
|
||||
* container and is trivially bypassed by writing the outbound system row
|
||||
* directly, so authorization MUST be enforced host-side. Trusted owner agent
|
||||
* groups (CLI scope 'global') create directly; every other (confined) group
|
||||
* requires admin approval via `requestApproval` — matching `ncl groups create`
|
||||
* (access: 'approval') and the self-mod actions. `applyCreateAgent` runs the
|
||||
* creation on approve; `performCreateAgent` is the shared body.
|
||||
* directly, so authorization MUST be enforced host-side: the delivery
|
||||
* registry wraps this action with the guard, whose `agents.create` baseline
|
||||
* (./guard.ts) is the old cli_scope branch verbatim — trusted global-scope
|
||||
* groups allow, everything else (including unknown config, fail-closed)
|
||||
* holds for admin approval. On approve the continuation re-enters the
|
||||
* wrapped action with the approval row as its grant and `createAgent` runs.
|
||||
* `performCreateAgent` is the module-private body.
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
@@ -23,7 +25,7 @@ import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { AgentGroup, Session } from '../../types.js';
|
||||
import { requestApproval, type ApprovalHandler } from '../approvals/index.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { createDestination, getDestinationByName, normalizeName } from './db/agent-destinations.js';
|
||||
import { writeDestinations } from './write-destinations.js';
|
||||
|
||||
@@ -43,41 +45,27 @@ function notifyAgent(session: Session, text: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery-action entry.
|
||||
*
|
||||
* Authorization depends on the calling group's CLI scope:
|
||||
* - `global` (set by init-first-agent for trusted owner agent groups):
|
||||
* create immediately. create_agent is the intended primitive for these
|
||||
* privileged agents, and an approval tap on every sub-agent spawn would be
|
||||
* needless friction.
|
||||
* - anything else (the default `group` scope — the realistic
|
||||
* prompt-injection victim): require an admin to approve before any
|
||||
* central-DB write. `applyCreateAgent` runs on approve.
|
||||
* Unknown/missing config fails closed to the approval path.
|
||||
*/
|
||||
export async function handleCreateAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
/** Guard precheck: malformed requests are answered without ever creating a hold. */
|
||||
export function validateCreateAgent(content: Record<string, unknown>, session: Session): boolean {
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notifyAgent(session, 'create_agent failed: name is required.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
if (!getAgentGroup(session.agent_group_id)) {
|
||||
notifyAgent(session, 'create_agent failed: source agent group not found.');
|
||||
log.warn('create_agent failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const cliScope = getContainerConfig(session.agent_group_id)?.cli_scope ?? 'group';
|
||||
if (cliScope === 'global') {
|
||||
// Trusted owner agent group — create directly, then notify (+wake) it.
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
|
||||
return;
|
||||
}
|
||||
/** Guard hold: card the requesting group's admin chain. */
|
||||
export async function requestCreateAgentHold(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) return;
|
||||
|
||||
await requestApproval({
|
||||
session,
|
||||
@@ -89,35 +77,22 @@ export async function handleCreateAgent(content: Record<string, unknown>, sessio
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval handler: performs the creation once an admin approves a request from
|
||||
* a confined (non-global) agent group. `session` is the requesting parent.
|
||||
*/
|
||||
export const applyCreateAgent: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
const name = typeof payload.name === 'string' ? payload.name : '';
|
||||
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
|
||||
|
||||
if (!name) {
|
||||
notify('create_agent approved but the request had no name.');
|
||||
return;
|
||||
}
|
||||
|
||||
/** Guard allow body: performs the creation (fresh global-scope call or approved replay). */
|
||||
export async function createAgent(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const name = typeof content.name === 'string' ? content.name : '';
|
||||
const instructions = typeof content.instructions === 'string' ? content.instructions : null;
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup) {
|
||||
notify('create_agent approved but the source agent group no longer exists.');
|
||||
log.warn('create_agent apply failed: missing source group', { sessionAgentGroup: session.agent_group_id, name });
|
||||
return;
|
||||
}
|
||||
if (!name || !sourceGroup) return; // precheck already answered the requester
|
||||
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, notify);
|
||||
};
|
||||
await performCreateAgent(name, instructions, session, sourceGroup, (text) => notifyAgent(session, text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Core creation: writes the new agent group + bidirectional destinations and
|
||||
* scaffolds its filesystem, then reports via `notify`. Authorization is the
|
||||
* CALLER's responsibility (the global-scope shortcut in handleCreateAgent or
|
||||
* admin approval via applyCreateAgent) — never call this from an unauthorized
|
||||
* path, as it performs privileged central-DB writes a confined container is
|
||||
* CALLER's responsibility (the guard's agents.create decision) — never call
|
||||
* this from an unauthorized path, as it performs privileged central-DB
|
||||
* writes a confined container is
|
||||
* otherwise barred from.
|
||||
*/
|
||||
async function performCreateAgent(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Agent-to-agent guard adapter — the module's catalog entries, composed at
|
||||
* the module edge (imported by ./index.ts).
|
||||
*
|
||||
* agents.create — the cli_scope branch moved verbatim out of
|
||||
* create-agent.ts: `global` scope creates directly (create_agent is the
|
||||
* intended primitive for trusted owner agent groups); anything else — the
|
||||
* default `group` scope, and unknown/missing config, fail-closed — holds for
|
||||
* the requesting group's admin chain.
|
||||
*
|
||||
* a2a.send — the decision moved verbatim out of routeAgentMessage, in its
|
||||
* original check order: a missing destination row denies; a missing target
|
||||
* group denies; self-sends allow without a destination row; an
|
||||
* agent_message_policies row for the (from, to) pair holds for the row's
|
||||
* named approver. The ghost-policy edge (policy row with no destination row)
|
||||
* denies — the destination check precedes the policy check, exactly today's
|
||||
* outcome. Policy rows can only tighten (hold), never allow: absence of a
|
||||
* row falls through to the structural checks.
|
||||
*/
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getContainerConfig } from '../../db/container-configs.js';
|
||||
import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy } from './db/agent-message-policies.js';
|
||||
|
||||
/**
|
||||
* pending_approvals action string for held a2a messages. Lives here (not in
|
||||
* agent-route.ts) so agent-route can import this adapter — loading the
|
||||
* consult site guarantees its catalog entry is registered — without a cycle.
|
||||
*/
|
||||
export const A2A_MESSAGE_GATE_ACTION = 'a2a_message_gate';
|
||||
|
||||
export const agentsCreate = defineGuardedAction({
|
||||
action: 'agents.create',
|
||||
approvalAction: 'create_agent',
|
||||
// Bind a create_agent grant to the name that was approved.
|
||||
grantMatches: (grant, input) => {
|
||||
try {
|
||||
return (JSON.parse(grant.payload) as { name?: string }).name === input.payload.name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (input) => {
|
||||
if (input.actor.kind !== 'agent') return DENY('create_agent is a container-originated action.');
|
||||
const cliScope = getContainerConfig(input.actor.agentGroupId)?.cli_scope ?? 'group';
|
||||
if (cliScope === 'global') {
|
||||
// Trusted owner agent group — an approval tap on every sub-agent spawn
|
||||
// would be needless friction.
|
||||
return ALLOW('trusted global-scope agent group');
|
||||
}
|
||||
// The realistic prompt-injection victim (default `group` scope) — and any
|
||||
// unknown config value, fail-closed — requires an admin before any
|
||||
// central-DB write.
|
||||
return HOLD('agent-initiated create_agent requires admin approval');
|
||||
},
|
||||
});
|
||||
|
||||
export const a2aSend = defineGuardedAction({
|
||||
action: 'a2a.send',
|
||||
approvalAction: A2A_MESSAGE_GATE_ACTION,
|
||||
// Bind an a2a grant to the exact held message target.
|
||||
grantMatches: (grant, input) => {
|
||||
try {
|
||||
return (JSON.parse(grant.payload) as { platform_id?: string }).platform_id === input.resource?.to;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
baseline: (input) => {
|
||||
if (input.actor.kind !== 'agent') return DENY('agent-to-agent send requires an agent actor');
|
||||
const from = input.actor.agentGroupId;
|
||||
const to = input.resource?.to ?? '';
|
||||
const isSelf = to === from;
|
||||
if (!isSelf && !hasDestination(from, 'agent', to)) {
|
||||
return DENY(`unauthorized agent-to-agent: ${from} has no destination for ${to}`);
|
||||
}
|
||||
if (!getAgentGroup(to)) {
|
||||
return DENY(`target agent group ${to} not found for message ${String(input.payload.id)}`);
|
||||
}
|
||||
if (isSelf) return ALLOW('self-send');
|
||||
const policy = getMessagePolicy(from, to);
|
||||
if (policy) {
|
||||
return HOLD(`a2a message policy ${from}→${to} holds for ${policy.approver}`, policy.approver);
|
||||
}
|
||||
return ALLOW('destination grant exists');
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Agent-to-agent module — inter-agent messaging and on-demand agent creation.
|
||||
*
|
||||
* Registers one delivery action (`create_agent`) plus its matching approval
|
||||
* handler — `create_agent` writes central-DB state, so confined (non-global)
|
||||
* groups require admin approval (the delivery action queues the request;
|
||||
* `applyCreateAgent` runs on approve); trusted global-scope groups create
|
||||
* directly. The sibling `channel_type === 'agent'` routing path is NOT a system
|
||||
* action — core `delivery.ts` dispatches into `./agent-route.js` via a dynamic
|
||||
* import when it sees `msg.channel_type === 'agent'`.
|
||||
* Registers its guard-catalog entries (./guard.js) and one guard-wrapped
|
||||
* delivery action (`create_agent`) — `create_agent` writes central-DB state,
|
||||
* so the guard's agents.create baseline holds confined (non-global) groups
|
||||
* for admin approval while trusted global-scope groups create directly; the
|
||||
* approval handler re-enters the wrapped action carrying the approval row as
|
||||
* its grant. The sibling `channel_type === 'agent'` routing path is NOT a
|
||||
* system action — core `delivery.ts` dispatches into `./agent-route.js` via
|
||||
* a dynamic import when it sees `msg.channel_type === 'agent'`.
|
||||
*
|
||||
* Host integration points:
|
||||
* - `src/container-runner.ts::spawnContainer` dynamically imports
|
||||
@@ -20,13 +21,19 @@
|
||||
* system action logs "Unknown system action", `channel_type='agent'` messages
|
||||
* throw because the module isn't installed.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js';
|
||||
import { notifyAgent, registerApprovalHandler } from '../approvals/index.js';
|
||||
import { A2A_MESSAGE_GATE_ACTION } from './agent-route.js';
|
||||
import { applyCreateAgent, handleCreateAgent } from './create-agent.js';
|
||||
import { createAgent, requestCreateAgentHold, validateCreateAgent } from './create-agent.js';
|
||||
import { agentsCreate } from './guard.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
|
||||
registerDeliveryAction('create_agent', handleCreateAgent);
|
||||
registerApprovalHandler('create_agent', applyCreateAgent);
|
||||
registerDeliveryAction('create_agent', createAgent, {
|
||||
guardAction: agentsCreate,
|
||||
precheck: validateCreateAgent,
|
||||
requestHold: requestCreateAgentHold,
|
||||
onDeny: (_content, session, reason) => notifyAgent(session, `create_agent denied: ${reason}`),
|
||||
});
|
||||
registerApprovalHandler('create_agent', reenterGuardedDeliveryAction('create_agent'));
|
||||
|
||||
registerApprovalHandler(A2A_MESSAGE_GATE_ACTION, applyA2aMessageGate);
|
||||
|
||||
@@ -2,16 +2,17 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import './guard.js'; // register the a2a.send catalog entry (incl. the policy hold)
|
||||
import { routeAgentMessage } from './agent-route.js';
|
||||
import { createDestination, deleteDestination, deleteAllDestinationsTouching } from './db/agent-destinations.js';
|
||||
import { getMessagePolicy, removeMessagePolicy, setMessagePolicy } from './db/agent-message-policies.js';
|
||||
import { applyA2aMessageGate } from './message-gate.js';
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { createPendingApproval, createSession, deletePendingApproval, getPendingApproval } from '../../db/sessions.js';
|
||||
import { requestApproval } from '../approvals/index.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -67,6 +68,23 @@ function makeSession(id: string, agentGroupId: string): Session {
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed a live a2a hold row (what requestApproval writes) and return it as the grant. */
|
||||
function seedA2aHold(approvalId: string, payload: Record<string, unknown>): PendingApproval {
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: 'sess-A',
|
||||
request_id: approvalId,
|
||||
action: 'a2a_message_gate',
|
||||
payload: JSON.stringify(payload),
|
||||
created_at: now(),
|
||||
agent_group_id: A,
|
||||
title: 'Message approval',
|
||||
options_json: '[]',
|
||||
approver_user_id: 'telegram:dana',
|
||||
});
|
||||
return getPendingApproval(approvalId)!;
|
||||
}
|
||||
|
||||
describe('agent message policies', () => {
|
||||
let SA: Session;
|
||||
let SB: Session;
|
||||
@@ -129,7 +147,7 @@ describe('agent message policies', () => {
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('policy present → holds the message and requests approval from the policy approver scoped to the target', async () => {
|
||||
it('policy present → holds the message and requests approval from the policy approver', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
|
||||
await routeAgentMessage(
|
||||
@@ -139,7 +157,7 @@ describe('agent message policies', () => {
|
||||
|
||||
// Held: nothing routed to B.
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
// One approval requested, to the policy's approver, scoped to the target group.
|
||||
// One approval requested, to the policy's approver.
|
||||
expect(requestApproval).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(requestApproval).mock.calls[0][0];
|
||||
expect(opts.action).toBe('a2a_message_gate');
|
||||
@@ -158,21 +176,71 @@ describe('agent message policies', () => {
|
||||
expect(readInbound(A, SA.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── approve handler re-routes the held message ──
|
||||
it('ghost policy (policy row, no destination row) still denies — deny beats the policy hold', async () => {
|
||||
deleteDestination(A, 'b'); // removes A→B — the destination ACL now denies
|
||||
setMessagePolicy(A, B, 'telegram:dana', now()); // ...but a stale policy row remains
|
||||
|
||||
await expect(
|
||||
routeAgentMessage({ id: 'ghost', platform_id: B, content: JSON.stringify({ text: 'x' }), in_reply_to: null }, SA),
|
||||
).rejects.toThrow(/unauthorized agent-to-agent/);
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── approve handler re-enters the guarded route with the grant ──
|
||||
|
||||
it('applyA2aMessageGate delivers the held message to the target (valid grant)', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
const payload = { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null };
|
||||
const approval = seedA2aHold('appr-a2a-1', payload);
|
||||
|
||||
it('applyA2aMessageGate delivers the held message to the target', async () => {
|
||||
const notify = vi.fn();
|
||||
await applyA2aMessageGate({
|
||||
session: SA,
|
||||
userId: 'slack:dana',
|
||||
notify,
|
||||
payload: { id: 'held-1', platform_id: B, content: JSON.stringify({ text: 'approved!' }), in_reply_to: null },
|
||||
});
|
||||
await applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify, payload, approval });
|
||||
|
||||
const bRows = readInbound(B, SB.id);
|
||||
expect(bRows).toHaveLength(1);
|
||||
expect(JSON.parse(bRows[0].content).text).toBe('approved!');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
// The hold is satisfied by the grant — no second card.
|
||||
expect(requestApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('destination revoked between hold and approve → the approved replay is blocked', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
const payload = { id: 'held-2', platform_id: B, content: JSON.stringify({ text: 'stale' }), in_reply_to: null };
|
||||
const approval = seedA2aHold('appr-a2a-2', payload);
|
||||
|
||||
deleteDestination(A, 'b'); // revoke A→B while the card is pending
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/unauthorized agent-to-agent/);
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('mismatched grant (held for another target) refuses the replay', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
// Grant was approved for a message to A (different target than the replay).
|
||||
const approval = seedA2aHold('appr-a2a-3', { id: 'other', platform_id: A, content: '{}', in_reply_to: null });
|
||||
const payload = { id: 'held-3', platform_id: B, content: JSON.stringify({ text: 'swap' }), in_reply_to: null };
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/invalid or mismatched grant/);
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a grant only works while its row is live (executes once)', async () => {
|
||||
setMessagePolicy(A, B, 'telegram:dana', now());
|
||||
const payload = { id: 'held-4', platform_id: B, content: JSON.stringify({ text: 'once' }), in_reply_to: null };
|
||||
const approval = seedA2aHold('appr-a2a-4', payload);
|
||||
|
||||
deletePendingApproval(approval.approval_id); // resolution already consumed the row
|
||||
|
||||
await expect(
|
||||
applyA2aMessageGate({ session: SA, userId: 'telegram:dana', notify: vi.fn(), payload, approval }),
|
||||
).rejects.toThrow(/invalid or mismatched grant/);
|
||||
expect(readInbound(B, SB.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── ghost-gate cleanup ──
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Approve handler for a held a2a message. (Reject is handled by the generic response-handler path.) */
|
||||
import { log } from '../../log.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
import { performAgentRoute, type RoutableAgentMessage } from './agent-route.js';
|
||||
import { routeAgentMessage, type RoutableAgentMessage } from './agent-route.js';
|
||||
|
||||
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, notify }) => {
|
||||
export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, approval, notify }) => {
|
||||
const { id, platform_id, content, in_reply_to } = payload;
|
||||
if (typeof platform_id !== 'string' || !platform_id) {
|
||||
notify('Message approved but the target agent group was missing from the request.');
|
||||
@@ -18,7 +18,12 @@ export const applyA2aMessageGate: ApprovalHandler = async ({ session, payload, n
|
||||
in_reply_to: typeof in_reply_to === 'string' ? in_reply_to : null,
|
||||
};
|
||||
|
||||
await performAgentRoute(msg, session, platform_id);
|
||||
// One replay semantics: re-enter the guarded route carrying the approval
|
||||
// row as the grant. The policy hold is satisfied, but the structural
|
||||
// baseline runs live — un-wiring the pair between hold and approve now
|
||||
// blocks delivery (the throw surfaces via the response handler's
|
||||
// "approved, but applying it failed" notify).
|
||||
await routeAgentMessage(msg, session, { grant: approval });
|
||||
log.info('Held agent message delivered after approval', {
|
||||
from: session.agent_group_id,
|
||||
to: platform_id,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* + approval handlers via this module's public API.
|
||||
*/
|
||||
import { onDeliveryAdapterReady } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import { registerResponseHandler, onShutdown } from '../../response-registry.js';
|
||||
import { handleApprovalsResponse } from './response-handler.js';
|
||||
import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js';
|
||||
@@ -34,7 +35,10 @@ export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions }
|
||||
// loads reason-capture.js, registering its message-interceptor on import.
|
||||
export { sweepAwaitingReasonRejects } from './reason-capture.js';
|
||||
|
||||
registerResponseHandler(handleApprovalsResponse);
|
||||
registerResponseHandler(
|
||||
handleApprovalsResponse,
|
||||
unguarded('self-authorizing — resolves each pending_approvals row against its own approver rules before dispatching'),
|
||||
);
|
||||
|
||||
onDeliveryAdapterReady((adapter) => {
|
||||
startOneCLIApprovalHandler(adapter);
|
||||
|
||||
@@ -59,6 +59,12 @@ const APPROVAL_OPTIONS: RawOption[] = [
|
||||
export interface ApprovalHandlerContext {
|
||||
session: Session;
|
||||
payload: Record<string, unknown>;
|
||||
/**
|
||||
* The verified approval row — the grant an approved continuation carries
|
||||
* when it re-enters its guarded entry point. Still live here; resolution
|
||||
* deletes it after the handler returns, so a grant executes exactly once.
|
||||
*/
|
||||
approval: PendingApproval;
|
||||
/** User ID of the admin who approved. Empty string if unknown. */
|
||||
userId: string;
|
||||
/** Send a system chat message to the requesting agent's session. */
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import {
|
||||
deletePendingApproval,
|
||||
getExpiredAwaitingReasonApprovals,
|
||||
@@ -151,7 +152,10 @@ export async function captureReasonReply(event: InboundEvent): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
registerMessageInterceptor(captureReasonReply);
|
||||
registerMessageInterceptor(
|
||||
captureReasonReply,
|
||||
unguarded('self-authorizing — only captures a DM from the admin whose reject click armed it (keyed by userId + DM)'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Host-sweep finalizer: any reject-with-reason hold whose window elapsed (admin
|
||||
|
||||
@@ -112,7 +112,7 @@ async function handleRegisteredApproval(
|
||||
|
||||
const payload = JSON.parse(approval.payload);
|
||||
try {
|
||||
await handler({ session, payload, userId, notify });
|
||||
await handler({ session, payload, approval, userId, notify });
|
||||
log.info('Approval handled', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
} catch (err) {
|
||||
log.error('Approval handler threw', { approvalId: approval.approval_id, action: approval.action, err });
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// registers its handlers at import time.
|
||||
import './approvals/index.js';
|
||||
import './interactive/index.js';
|
||||
import './scheduling/index.js';
|
||||
import './permissions/index.js';
|
||||
import './agent-to-agent/index.js';
|
||||
import './self-mod/index.js';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
@@ -56,4 +57,7 @@ async function handleInteractiveResponse(payload: ResponsePayload): Promise<bool
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleInteractiveResponse);
|
||||
registerResponseHandler(
|
||||
handleInteractiveResponse,
|
||||
unguarded('not privileged — relays an in-chat answer back into the asking session; only the question row is touched'),
|
||||
);
|
||||
|
||||
@@ -54,7 +54,11 @@ vi.mock('./user-dm.js', () => ({
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channel-approval' };
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-channel-approval',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-channel-approval/groups',
|
||||
};
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-channel-approval';
|
||||
@@ -438,6 +442,108 @@ describe('unknown-channel registration flow', () => {
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
|
||||
it('create new agent: the free-text name reply creates the group and wires the channel', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
await routeInbound(groupMention('chat-create-new'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
|
||||
// Owner clicks "Connect new agent" → name prompt lands in their DM.
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'new_agent',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// Owner replies with the agent name in the same DM — the guarded
|
||||
// interceptor allows (still an eligible approver) and creates.
|
||||
await routeInbound({
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'name-reply-1',
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Newbie' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const created = getDb().prepare("SELECT id FROM agent_groups WHERE name = 'Newbie'").get() as
|
||||
| { id: string }
|
||||
| undefined;
|
||||
expect(created).toBeDefined();
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ? AND agent_group_id = ?')
|
||||
.get(pending.messaging_group_id, created!.id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(1);
|
||||
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
|
||||
.c;
|
||||
expect(stillPending).toBe(0);
|
||||
});
|
||||
|
||||
it('a name reply after the registration vanished is consumed without creating anything', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
await routeInbound(groupMention('chat-vanished'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'new_agent',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// The registration disappears between the click and the reply (rejected
|
||||
// from another card, group delete cascade, …) — the guard's baseline no
|
||||
// longer finds a pending registration, so the reply must not create.
|
||||
getDb()
|
||||
.prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?')
|
||||
.run(pending.messaging_group_id);
|
||||
|
||||
const agentGroupsBefore = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c;
|
||||
await routeInbound({
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
threadId: null,
|
||||
message: {
|
||||
id: 'name-reply-2',
|
||||
kind: 'chat' as const,
|
||||
content: JSON.stringify({ senderId: 'owner', senderName: 'Owner', text: 'Ghost' }),
|
||||
timestamp: now(),
|
||||
},
|
||||
});
|
||||
|
||||
const agentGroupsAfter = (getDb().prepare('SELECT COUNT(*) AS c FROM agent_groups').get() as { c: number }).c;
|
||||
expect(agentGroupsAfter).toBe(agentGroupsBefore);
|
||||
const mgaCount = (getDb().prepare('SELECT COUNT(*) AS c FROM messaging_group_agents').get() as { c: number }).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-owner / no-agent failure modes', () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Permissions guard adapter — the module's catalog entries, composed at the
|
||||
* module edge (imported by ./index.ts).
|
||||
*
|
||||
* senders.admit — the `unknown_sender_policy` switch moved verbatim out of
|
||||
* handleUnknownSender: `public` allows (short-circuited before the gate
|
||||
* anyway), `request_approval` holds, `strict` denies. The hold is executed by
|
||||
* the caller through the module's own pending_sender_approvals flow (card,
|
||||
* in-flight dedup) — not the approvals primitive — so this entry has no
|
||||
* approvalAction: the approve continuation adds the member and replays
|
||||
* routeInbound, which then passes the gate structurally via membership, no
|
||||
* grant needed.
|
||||
*
|
||||
* channels.register — click/reply authorization for the channel-registration
|
||||
* flow, verbatim from today's response handler: the delivered approver, or an
|
||||
* admin of the pending row's anchor agent group. Consulted by the wrapped
|
||||
* response handler (card clicks) and the wrapped name-capture interceptor
|
||||
* (free-text replies), so a privilege revoked mid-flow is re-checked at each
|
||||
* step.
|
||||
*/
|
||||
import { ALLOW, DENY, HOLD, defineGuardedAction } from '../../guard/index.js';
|
||||
import { getPendingChannelApproval } from './db/pending-channel-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
|
||||
export const sendersAdmit = defineGuardedAction({
|
||||
action: 'senders.admit',
|
||||
baseline: (input) => {
|
||||
const policy = input.payload.policy;
|
||||
if (policy === 'public') return ALLOW('public messaging group');
|
||||
if (policy === 'request_approval') {
|
||||
return HOLD(
|
||||
`unknown sender requires admin approval on messaging group ${String(input.payload.messagingGroupId)}`,
|
||||
);
|
||||
}
|
||||
return DENY('unknown sender on a strict messaging group');
|
||||
},
|
||||
});
|
||||
|
||||
export const channelsRegister = defineGuardedAction({
|
||||
action: 'channels.register',
|
||||
baseline: (input) => {
|
||||
if (input.actor.kind !== 'human') return DENY('channel registration resolves via human clicks/replies');
|
||||
const questionId = typeof input.payload.questionId === 'string' ? input.payload.questionId : '';
|
||||
const row = getPendingChannelApproval(questionId);
|
||||
if (!row) return DENY(`no pending channel registration for ${questionId || '(missing questionId)'}`);
|
||||
if (
|
||||
input.actor.userId &&
|
||||
(input.actor.userId === row.approver_user_id || hasAdminPrivilege(input.actor.userId, row.agent_group_id))
|
||||
) {
|
||||
return ALLOW('delivered approver or anchor-group admin');
|
||||
}
|
||||
return DENY('not an eligible channel-registration approver');
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,8 @@ import { registerResponseHandler, type ResponsePayload } from '../../response-re
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { guard, unguarded } from '../../guard/index.js';
|
||||
import { channelsRegister, sendersAdmit } from './guard.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import {
|
||||
buildAgentSelectionOptions,
|
||||
@@ -129,43 +131,49 @@ function handleUnknownSender(
|
||||
agent_group_id: agentGroupId,
|
||||
};
|
||||
|
||||
if (mg.unknown_sender_policy === 'strict') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (strict policy)', {
|
||||
// The admission decision is the guard's senders.admit baseline (./guard.ts)
|
||||
// — unknown_sender_policy verbatim: strict → deny, request_approval → hold,
|
||||
// public → allow (short-circuited before the gate). Drop-recording and the
|
||||
// hold creation stay here.
|
||||
const decision = guard(sendersAdmit, {
|
||||
actor: userId ? { kind: 'human', userId } : { kind: 'system' },
|
||||
payload: {
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
senderIdentity: userId,
|
||||
policy: mg.unknown_sender_policy,
|
||||
},
|
||||
});
|
||||
|
||||
if (decision.effect === 'allow') return; // 'public' — handled before the gate; fall through silently.
|
||||
|
||||
log.info(
|
||||
decision.effect === 'hold'
|
||||
? 'MESSAGE DROPPED — unknown sender (approval requested)'
|
||||
: 'MESSAGE DROPPED — unknown sender (strict policy)',
|
||||
{
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
recordDroppedMessage(dropRecord);
|
||||
|
||||
if (mg.unknown_sender_policy === 'request_approval') {
|
||||
log.info('MESSAGE DROPPED — unknown sender (approval requested)', {
|
||||
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
|
||||
// If it fails it logs internally — the user's message still stays dropped
|
||||
// either way. Requires a resolved userId (senderResolver populates users
|
||||
// row before the gate fires); if we got here without one, there's nothing
|
||||
// to identify for approval and we just drop silently.
|
||||
if (decision.effect === 'hold' && userId) {
|
||||
requestSenderApproval({
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
userId,
|
||||
accessReason,
|
||||
});
|
||||
recordDroppedMessage(dropRecord);
|
||||
// Fire-and-forget; pick-approver + delivery + row-insert are all async.
|
||||
// If it fails it logs internally — the user's message still stays dropped
|
||||
// either way. Requires a resolved userId (senderResolver populates users
|
||||
// row before the gate fires); if we got here without one, there's nothing
|
||||
// to identify for approval and we just stay in the "silent strict" branch.
|
||||
if (userId) {
|
||||
requestSenderApproval({
|
||||
messagingGroupId: mg.id,
|
||||
agentGroupId,
|
||||
senderIdentity: userId,
|
||||
senderName,
|
||||
event,
|
||||
}).catch((err) => log.error('Sender-approval flow threw', { err }));
|
||||
}
|
||||
return;
|
||||
senderIdentity: userId,
|
||||
senderName,
|
||||
event,
|
||||
}).catch((err) => log.error('Sender-approval flow threw', { err }));
|
||||
}
|
||||
|
||||
// 'public' should have been handled before the gate; fall through silently.
|
||||
}
|
||||
|
||||
setSenderResolver(extractAndUpsertUser);
|
||||
@@ -285,7 +293,12 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleSenderApprovalResponse);
|
||||
registerResponseHandler(
|
||||
handleSenderApprovalResponse,
|
||||
unguarded(
|
||||
'self-authorizing — verifies the clicker inline (delivered approver or group admin) before adding a member',
|
||||
),
|
||||
);
|
||||
|
||||
// ── Unknown-channel registration flow ──
|
||||
|
||||
@@ -311,21 +324,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
// Click authorization is the guard's channels.register baseline (./guard.ts),
|
||||
// consulted by the response-registry wrapper before this handler runs.
|
||||
const clickerId = payload.userId
|
||||
? payload.userId.includes(':')
|
||||
? payload.userId
|
||||
: `${payload.channelType}:${payload.userId}`
|
||||
: null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
if (!isAuthorized) {
|
||||
log.warn('Channel registration click rejected — unauthorized clicker', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (!clickerId) return true; // unreachable behind the guard wrapper; fail closed
|
||||
const approverId = clickerId;
|
||||
|
||||
// ── Reject / Cancel ──
|
||||
@@ -515,13 +521,19 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
return true;
|
||||
}
|
||||
|
||||
registerResponseHandler(handleChannelApprovalResponse);
|
||||
registerResponseHandler(handleChannelApprovalResponse, {
|
||||
action: channelsRegister,
|
||||
claims: (payload) => getPendingChannelApproval(payload.questionId) !== undefined,
|
||||
});
|
||||
|
||||
// ── Free-text name interceptor ──
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
// creates the agent immediately, wires the channel, and replays. The router
|
||||
// wraps it with the guard: the free-texter must still be an eligible
|
||||
// channel-registration approver at reply time — a privilege revoked between
|
||||
// the click and the reply now denies, and the arming is disarmed.
|
||||
|
||||
registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const captureAgentNameReply = async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
@@ -629,4 +641,20 @@ registerMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
registerMessageInterceptor(captureAgentNameReply, {
|
||||
action: channelsRegister,
|
||||
claims: (event) => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return null;
|
||||
const pending = awaitingNameInput.get(userId);
|
||||
if (!pending) return null;
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return null;
|
||||
return { actor: { kind: 'human', userId }, payload: { questionId: pending.channelMgId } };
|
||||
},
|
||||
onDeny: (event) => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (userId) awaitingNameInput.delete(userId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Delivery action handlers for scheduling.
|
||||
*
|
||||
* The container can't write to inbound.db (host-owned). When the agent calls
|
||||
* schedule_task / cancel_task / etc. via MCP, the container writes a
|
||||
* `kind='system'` outbound message with an `action` field. The delivery path
|
||||
* reaches into this module via the delivery-action registry and we apply the
|
||||
* change to inbound.db here.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { cancelTask, insertTask, pauseTask, resumeTask, updateTask, type TaskUpdate } from './db.js';
|
||||
|
||||
export async function handleScheduleTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
const prompt = content.prompt as string;
|
||||
const script = content.script as string | null;
|
||||
const processAfter = content.processAfter as string;
|
||||
const recurrence = (content.recurrence as string) || null;
|
||||
|
||||
insertTask(inDb, {
|
||||
id: taskId,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: (content.platformId as string) ?? null,
|
||||
channelType: (content.channelType as string) ?? null,
|
||||
threadId: (content.threadId as string) ?? null,
|
||||
content: JSON.stringify({ prompt, script }),
|
||||
});
|
||||
log.info('Scheduled task created', { taskId, processAfter, recurrence });
|
||||
}
|
||||
|
||||
export async function handleCancelTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
cancelTask(inDb, taskId);
|
||||
log.info('Task cancelled', { taskId });
|
||||
}
|
||||
|
||||
export async function handlePauseTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
pauseTask(inDb, taskId);
|
||||
log.info('Task paused', { taskId });
|
||||
}
|
||||
|
||||
export async function handleResumeTask(
|
||||
content: Record<string, unknown>,
|
||||
_session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
resumeTask(inDb, taskId);
|
||||
log.info('Task resumed', { taskId });
|
||||
}
|
||||
|
||||
export async function handleUpdateTask(
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
): Promise<void> {
|
||||
const taskId = content.taskId as string;
|
||||
const update: TaskUpdate = {};
|
||||
if (typeof content.prompt === 'string') update.prompt = content.prompt;
|
||||
if (typeof content.processAfter === 'string') update.processAfter = content.processAfter;
|
||||
if (content.recurrence === null || typeof content.recurrence === 'string') {
|
||||
update.recurrence = content.recurrence as string | null;
|
||||
}
|
||||
if (content.script === null || typeof content.script === 'string') {
|
||||
update.script = content.script as string | null;
|
||||
}
|
||||
const touched = updateTask(inDb, taskId, update);
|
||||
log.info('Task updated', { taskId, touched, fields: Object.keys(update) });
|
||||
if (touched === 0) {
|
||||
// Notify the agent that update_task matched nothing. Replicates the
|
||||
// old notifyAgent helper that used to live in delivery.ts — inlined
|
||||
// here so scheduling doesn't depend on delivery's private helpers.
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: `update_task: no live task matched id "${taskId}".`,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
});
|
||||
const fresh = getSession(session.id);
|
||||
if (fresh) {
|
||||
wakeContainer(fresh).catch((err) =>
|
||||
log.error('Failed to wake container after update_task notification', { err }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
|
||||
import {
|
||||
insertTaskRow,
|
||||
insertTask,
|
||||
insertRecurrence,
|
||||
cancelTask,
|
||||
pauseTask,
|
||||
@@ -31,11 +31,13 @@ function freshDb() {
|
||||
}
|
||||
|
||||
function insertBasicTask(db: ReturnType<typeof openInboundDb>, id: string, recurrence: string | null) {
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id,
|
||||
seriesId: id,
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'noop' }),
|
||||
});
|
||||
}
|
||||
@@ -44,7 +46,7 @@ afterEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
describe('insertTaskRow', () => {
|
||||
describe('insertTask', () => {
|
||||
it('stamps series_id = id on insert', () => {
|
||||
const db = freshDb();
|
||||
insertBasicTask(db, 'task-1', null);
|
||||
@@ -66,8 +68,13 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: JSON.stringify({ prompt: 'noop' }),
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date(Date.now() + 86400000).toISOString());
|
||||
@@ -86,8 +93,7 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
status: string;
|
||||
recurrence: string | null;
|
||||
};
|
||||
// Cancel marks 'cancelled' (not 'completed') so it never counts as a run.
|
||||
expect(followUp.status).toBe('cancelled');
|
||||
expect(followUp.status).toBe('completed');
|
||||
// Recurrence cleared so the sweep doesn't spawn another clone.
|
||||
expect(followUp.recurrence).toBeNull();
|
||||
db.close();
|
||||
@@ -125,37 +131,18 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
|
||||
expect(followUp.status).toBe('pending');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('pause/resume touch ONLY status — recurrence and process_after survive the cycle', () => {
|
||||
const db = freshDb();
|
||||
seedRecurringChain(db);
|
||||
const before = db.prepare("SELECT recurrence, process_after FROM messages_in WHERE id = 'task-next'").get() as {
|
||||
recurrence: string | null;
|
||||
process_after: string | null;
|
||||
};
|
||||
|
||||
pauseTask(db, 'task-next');
|
||||
resumeTask(db, 'task-next');
|
||||
|
||||
const after = db.prepare("SELECT recurrence, process_after FROM messages_in WHERE id = 'task-next'").get() as {
|
||||
recurrence: string | null;
|
||||
process_after: string | null;
|
||||
};
|
||||
// A cancel-style copy-paste (clearing recurrence) would kill the series here.
|
||||
expect(after.recurrence).toBe(before.recurrence);
|
||||
expect(after.process_after).toBe(before.process_after);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('merges supplied fields into content JSON without clobbering others', () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'old', script: 'echo old', extra: 'keep me' }),
|
||||
});
|
||||
|
||||
@@ -171,11 +158,13 @@ describe('updateTask', () => {
|
||||
|
||||
it('updates recurrence and process_after when supplied', () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2026-01-01T00:00:00Z',
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
|
||||
@@ -191,11 +180,13 @@ describe('updateTask', () => {
|
||||
|
||||
it('clears recurrence when null is passed', () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2026-01-01T00:00:00Z',
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
|
||||
@@ -209,19 +200,26 @@ describe('updateTask', () => {
|
||||
|
||||
it('reaches the live follow-up via series_id when called with the original id', () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-orig',
|
||||
seriesId: 'task-orig',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: '0 9 * * *',
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'old' }),
|
||||
});
|
||||
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-orig'").run();
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: JSON.stringify({ prompt: 'old' }),
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date(Date.now() + 86400000).toISOString());
|
||||
@@ -240,11 +238,13 @@ describe('updateTask', () => {
|
||||
|
||||
it('returns 0 when no live task matches', () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: new Date().toISOString(),
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'p' }),
|
||||
});
|
||||
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-1'").run();
|
||||
@@ -262,8 +262,13 @@ describe('insertRecurrence', () => {
|
||||
|
||||
const msg: RecurringMessage = {
|
||||
id: 'task-orig',
|
||||
kind: 'task',
|
||||
content: '{}',
|
||||
recurrence: '0 9 * * *',
|
||||
process_after: null,
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
series_id: 'task-orig',
|
||||
};
|
||||
insertRecurrence(db, msg, 'task-next', new Date().toISOString());
|
||||
|
||||
@@ -14,71 +14,43 @@ import type Database from 'better-sqlite3';
|
||||
|
||||
import { nextEvenSeq } from '../../db/session-db.js';
|
||||
|
||||
/**
|
||||
* Insert one pending task occurrence. `seriesId` is the series join key — equal
|
||||
* to `id` for a brand-new series, or the existing series for a recurrence clone
|
||||
* or an on-demand run. Tasks never set platform/channel/thread (they fire into
|
||||
* an isolated system session), so those columns are always NULL.
|
||||
*/
|
||||
export function insertTaskRow(
|
||||
export function insertTask(
|
||||
db: Database.Database,
|
||||
row: {
|
||||
task: {
|
||||
id: string;
|
||||
seriesId: string;
|
||||
processAfter: string | null;
|
||||
processAfter: string;
|
||||
recurrence: string | null;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
status?: 'pending' | 'paused';
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (@id, @seq, datetime('now'), @status, 0, @processAfter, @recurrence, 'task', NULL, NULL, NULL, @content, @seriesId)`,
|
||||
VALUES (@id, @seq, datetime('now'), 'pending', 0, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content, @id)`,
|
||||
).run({
|
||||
status: 'pending',
|
||||
...row,
|
||||
...task,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel marks the live row 'cancelled' (not 'completed') so a never-fired
|
||||
// occurrence is distinguishable from a real run and never inflates run history;
|
||||
// recurrence is cleared so the series isn't re-armed by handleRecurrence.
|
||||
export function cancelTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'cancelled', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
export function cancelTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'completed', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export function cancelAllTasks(db: Database.Database): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'cancelled', recurrence = NULL WHERE kind = 'task' AND status IN ('pending', 'paused')",
|
||||
)
|
||||
.run().changes;
|
||||
export function pauseTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export function pauseTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
}
|
||||
|
||||
export function resumeTask(db: Database.Database, taskId: string): number {
|
||||
return db
|
||||
.prepare(
|
||||
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
|
||||
)
|
||||
.run(taskId, taskId).changes;
|
||||
}
|
||||
|
||||
export function deleteTask(db: Database.Database, taskId: string): number {
|
||||
return db.prepare("DELETE FROM messages_in WHERE (id = ? OR series_id = ?) AND kind = 'task'").run(taskId, taskId)
|
||||
.changes;
|
||||
export function resumeTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
@@ -135,64 +107,45 @@ export function updateTask(db: Database.Database, taskId: string, update: TaskUp
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Only tasks carry a recurrence (non-task writeSessionMessage never sets one),
|
||||
// so getCompletedRecurring only ever returns task rows — the fields below are
|
||||
// all that handleRecurrence needs to clone the next occurrence.
|
||||
export interface RecurringMessage {
|
||||
id: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
recurrence: string;
|
||||
process_after: string | null;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
series_id: string;
|
||||
}
|
||||
|
||||
// Failed occurrences (script-skip:error runs) re-arm too — a broken monitor
|
||||
// must keep its series alive so backoff can throttle it and the cap can pause
|
||||
// it; dropping the row would silently kill the series on first script error.
|
||||
export function getCompletedRecurring(db: Database.Database): RecurringMessage[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM messages_in WHERE status IN ('completed', 'failed') AND recurrence IS NOT NULL")
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
||||
.all() as RecurringMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing consecutive FAILED occurrences of a series, newest backwards until
|
||||
* the first completed run. This IS the script-failure streak — derived from
|
||||
* the occurrence history, no stored counter to update or reset. Deliberately
|
||||
* counts ANY failed occurrence (script-skip:error acks AND stuck-message
|
||||
* failures from host-sweep's MAX_TRIES path): a series failing for either
|
||||
* reason should throttle, not spin.
|
||||
*/
|
||||
export function trailingFailedRuns(db: Database.Database, seriesKey: string): number {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT status FROM messages_in
|
||||
WHERE (series_id = ? OR id = ?) AND kind = 'task' AND status IN ('completed', 'failed')
|
||||
ORDER BY seq DESC`,
|
||||
)
|
||||
.all(seriesKey, seriesKey) as Array<{ status: string }>;
|
||||
let streak = 0;
|
||||
for (const r of rows) {
|
||||
if (r.status !== 'failed') break;
|
||||
streak++;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export function insertRecurrence(
|
||||
db: Database.Database,
|
||||
msg: RecurringMessage,
|
||||
newId: string,
|
||||
nextRun: string | null,
|
||||
status: 'pending' | 'paused' = 'pending',
|
||||
): void {
|
||||
insertTaskRow(db, {
|
||||
id: newId,
|
||||
seriesId: msg.series_id,
|
||||
processAfter: nextRun,
|
||||
recurrence: msg.recurrence,
|
||||
content: msg.content,
|
||||
status,
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
newId,
|
||||
nextEvenSeq(db),
|
||||
msg.kind,
|
||||
nextRun,
|
||||
msg.recurrence,
|
||||
msg.platform_id,
|
||||
msg.channel_type,
|
||||
msg.thread_id,
|
||||
msg.content,
|
||||
msg.series_id,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearRecurrence(db: Database.Database, messageId: string): void {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Scheduling module — one-shot and recurring tasks.
|
||||
*
|
||||
* Registers:
|
||||
* - Five delivery action handlers: schedule_task, cancel_task, pause_task,
|
||||
* resume_task, update_task. The container's scheduling MCP tools
|
||||
* (container/agent-runner/src/mcp-tools/scheduling.ts) write system
|
||||
* messages with these actions; the host applies them to inbound.db.
|
||||
*
|
||||
* Host integration points (filled by MODULE-HOOK markers, validated here
|
||||
* with the scheduling module shipping inline):
|
||||
* - `src/host-sweep.ts` → MODULE-HOOK:scheduling-recurrence calls
|
||||
* `handleRecurrence` each sweep tick.
|
||||
* - `container/agent-runner/src/poll-loop.ts` → MODULE-HOOK:scheduling-pre-task
|
||||
* runs `applyPreTaskScripts` before the provider call so tasks carrying
|
||||
* a pre-agent script can gate their own execution.
|
||||
*
|
||||
* No DB migration — tasks are `messages_in` rows with `kind='task'`, so the
|
||||
* module piggybacks on the core schema.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { unguarded } from '../../guard/index.js';
|
||||
import {
|
||||
handleCancelTask,
|
||||
handlePauseTask,
|
||||
handleResumeTask,
|
||||
handleScheduleTask,
|
||||
handleUpdateTask,
|
||||
} from './actions.js';
|
||||
|
||||
const selfAction = unguarded('agent self-action — mutates only its own task rows; not a privileged class (yet)');
|
||||
|
||||
registerDeliveryAction('schedule_task', handleScheduleTask, selfAction);
|
||||
registerDeliveryAction('cancel_task', handleCancelTask, selfAction);
|
||||
registerDeliveryAction('pause_task', handlePauseTask, selfAction);
|
||||
registerDeliveryAction('resume_task', handleResumeTask, selfAction);
|
||||
registerDeliveryAction('update_task', handleUpdateTask, selfAction);
|
||||
@@ -8,20 +8,13 @@
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ensureSchema, openInboundDb } from '../../db/session-db.js';
|
||||
import { insertTaskRow } from './db.js';
|
||||
import { handleRecurrence, scriptBackoffMinutes } from './recurrence.js';
|
||||
import { insertTask } from './db.js';
|
||||
import { handleRecurrence } from './recurrence.js';
|
||||
import type { Session } from '../../types.js';
|
||||
|
||||
// Pin a non-UTC zone so the tz-interpretation test is exact even on UTC CI.
|
||||
// Asia/Tokyo is UTC+9 with no DST: "0 9 * * *" must land at 00:00:00Z sharp.
|
||||
vi.mock('../../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../config.js')>();
|
||||
return { ...actual, TIMEZONE: 'Asia/Tokyo' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-recurrence-test';
|
||||
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
|
||||
|
||||
@@ -52,11 +45,13 @@ afterEach(() => {
|
||||
describe('handleRecurrence', () => {
|
||||
it('clones a completed recurring task with a next-run in the future', async () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: '0 9 * * *', // every day at 09:00 (user TZ)
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'daily digest' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
@@ -82,34 +77,15 @@ describe('handleRecurrence', () => {
|
||||
expect(new Date(follow.process_after).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('interprets the cron expression in TIMEZONE, not UTC (the v1 regression)', async () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
id: 'task-tz',
|
||||
seriesId: 'task-tz',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: '0 9 * * *', // 09:00 Asia/Tokyo === 00:00 UTC, exactly
|
||||
content: JSON.stringify({ prompt: 'daily digest' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-tz'`).run();
|
||||
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const follow = db.prepare(`SELECT process_after FROM messages_in WHERE id != 'task-tz'`).get() as {
|
||||
process_after: string;
|
||||
};
|
||||
// Drop the `{ tz: TIMEZONE }` option in recurrence.ts and this reads
|
||||
// T09:00:00 (09:00 UTC) instead — red, even on a UTC CI runner.
|
||||
expect(follow.process_after).toMatch(/T00:00:00/);
|
||||
});
|
||||
|
||||
it('does not clone rows whose recurrence is already cleared', async () => {
|
||||
const db = freshDb();
|
||||
insertTaskRow(db, {
|
||||
insertTask(db, {
|
||||
id: 'task-1',
|
||||
seriesId: 'task-1',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: null,
|
||||
platformId: null,
|
||||
channelType: null,
|
||||
threadId: null,
|
||||
content: JSON.stringify({ prompt: 'one-off' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status='completed' WHERE id='task-1'`).run();
|
||||
@@ -120,74 +96,3 @@ describe('handleRecurrence', () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRecurrence — script-failure backoff (streak derived from failed runs)', () => {
|
||||
// A series whose last `fails` occurrences all landed as FAILED (script-skip:error
|
||||
// runs, as synced by syncProcessingAcks). Only the newest row keeps recurrence —
|
||||
// older occurrences had theirs cleared when they were re-armed. fails=0 seeds one
|
||||
// healthy completed run.
|
||||
function seedFailedStreak(db: ReturnType<typeof freshDb>, fails: number) {
|
||||
const rows = Math.max(fails, 1);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
insertTaskRow(db, {
|
||||
id: `task-s-${i}`,
|
||||
seriesId: 'task-s-0',
|
||||
processAfter: '2020-01-01T00:00:00.000Z',
|
||||
recurrence: i === rows - 1 ? '* * * * *' : null, // every minute — raw cron next is ~+1min
|
||||
content: JSON.stringify({ prompt: 'monitor', script: 'exit 1' }),
|
||||
});
|
||||
db.prepare(`UPDATE messages_in SET status = ? WHERE id = ?`).run(
|
||||
fails === 0 ? 'completed' : 'failed',
|
||||
`task-s-${i}`,
|
||||
);
|
||||
}
|
||||
return `task-s-${rows - 1}`; // the row carrying recurrence
|
||||
}
|
||||
|
||||
const clone = (db: ReturnType<typeof freshDb>) =>
|
||||
db.prepare(`SELECT status, process_after, recurrence FROM messages_in WHERE id NOT LIKE 'task-s-%'`).get() as {
|
||||
status: string;
|
||||
process_after: string;
|
||||
recurrence: string | null;
|
||||
};
|
||||
|
||||
it('exports the documented 2,4,8,…,60 progression', () => {
|
||||
expect([1, 2, 3, 4, 5, 6, 7].map(scriptBackoffMinutes)).toEqual([2, 4, 8, 16, 32, 60, 60]);
|
||||
});
|
||||
|
||||
it('pushes the clone past raw cron cadence while the script is failing', async () => {
|
||||
const db = freshDb();
|
||||
seedFailedStreak(db, 3); // streak 3 → backoff 8 min; cron next ≈ +1 min
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('pending');
|
||||
const deltaMin = (new Date(next.process_after).getTime() - Date.now()) / 60_000;
|
||||
expect(deltaMin).toBeGreaterThan(7); // backoff won over the 1-min cron grid
|
||||
});
|
||||
|
||||
it('a healthy series (trailing run completed) re-arms on the raw cron grid', async () => {
|
||||
const db = freshDb();
|
||||
seedFailedStreak(db, 0);
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('pending');
|
||||
const deltaMin = (new Date(next.process_after).getTime() - Date.now()) / 60_000;
|
||||
expect(deltaMin).toBeLessThan(2); // no backoff applied
|
||||
});
|
||||
|
||||
it('auto-pauses the series at the cap instead of re-arming', async () => {
|
||||
const db = freshDb();
|
||||
const liveId = seedFailedStreak(db, 8);
|
||||
await handleRecurrence(db, fakeSession());
|
||||
|
||||
const next = clone(db);
|
||||
expect(next.status).toBe('paused'); // `ncl tasks resume` revives in place
|
||||
expect(next.recurrence).toBe('* * * * *');
|
||||
const original = db.prepare(`SELECT recurrence FROM messages_in WHERE id = ?`).get(liveId) as {
|
||||
recurrence: string | null;
|
||||
};
|
||||
expect(original.recurrence).toBeNull(); // not re-cloned next sweep
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,79 +11,27 @@
|
||||
* direct dynamic import. When scheduling moves to the modules branch in
|
||||
* PR #8, the install skill re-fills the marker on install.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { GROUPS_DIR, TIMEZONE } from '../../config.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { TIMEZONE } from '../../config.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence, trailingFailedRuns } from './db.js';
|
||||
|
||||
// Consecutive pre-task-script failures (the series' trailing FAILED runs —
|
||||
// derived from occurrence rows, no stored counter) throttle a broken monitor
|
||||
// script instead of letting it wake a container at raw cron cadence forever.
|
||||
// A deliberate wakeAgent=false gate is a normal completed run and never backs
|
||||
// off. Mirrors the stuck-message retry in host-sweep.ts (BACKOFF_BASE_MS
|
||||
// doubling, MAX_TRIES → failed): fail loud, don't spin.
|
||||
const SCRIPT_FAIL_PAUSE_CAP = 8;
|
||||
const SCRIPT_BACKOFF_CAP_MIN = 60;
|
||||
|
||||
/** 2, 4, 8, 16, 32, 60, 60… minutes for fails = 1, 2, 3… */
|
||||
export function scriptBackoffMinutes(fails: number): number {
|
||||
return Math.min(2 * 2 ** (fails - 1), SCRIPT_BACKOFF_CAP_MIN);
|
||||
}
|
||||
|
||||
/** Host-written line in the series run log — no agent session exists to call
|
||||
* append-log when a script-gated series is auto-paused. Same format as
|
||||
* appendTaskLog (tasks.ts). */
|
||||
function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string): void {
|
||||
const ag = getAgentGroup(agentGroupId);
|
||||
if (!ag) return;
|
||||
const dir = path.join(GROUPS_DIR, ag.folder, 'tasks');
|
||||
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp} — ${note}\n`);
|
||||
}
|
||||
import { clearRecurrence, getCompletedRecurring, insertRecurrence } from './db.js';
|
||||
|
||||
export async function handleRecurrence(inDb: Database.Database, session: Session): Promise<void> {
|
||||
const recurring = getCompletedRecurring(inDb);
|
||||
|
||||
for (const msg of recurring) {
|
||||
try {
|
||||
const { CronExpressionParser } = await import('cron-parser');
|
||||
// Interpret the cron expression in the user's timezone. v1 did this
|
||||
// (src/v1/task-scheduler.ts:20-49); without it, a task written "0 9 * * *"
|
||||
// by an agent running in a user's local TZ fires at 09:00 UTC instead of
|
||||
// 09:00 user-local.
|
||||
const interval = CronExpressionParser.parse(msg.recurrence, { tz: TIMEZONE });
|
||||
const cronNext = interval.next().toDate();
|
||||
const newId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const scriptFails = trailingFailedRuns(inDb, msg.series_id ?? msg.id);
|
||||
|
||||
if (scriptFails >= SCRIPT_FAIL_PAUSE_CAP) {
|
||||
// Re-arm PAUSED at the cron time so `ncl tasks resume` revives the
|
||||
// series in place; leave the why in the run log.
|
||||
insertRecurrence(inDb, msg, newId, cronNext.toISOString(), 'paused');
|
||||
clearRecurrence(inDb, msg.id);
|
||||
appendHostTaskNote(
|
||||
session.agent_group_id,
|
||||
msg.series_id,
|
||||
`auto-paused after ${scriptFails} consecutive script failures (host); fix the script, then \`ncl tasks resume ${msg.series_id}\``,
|
||||
);
|
||||
log.warn('Task series auto-paused: script keeps failing', {
|
||||
seriesId: msg.series_id,
|
||||
scriptFails,
|
||||
sessionId: session.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const backoffAt = scriptFails > 0 ? Date.now() + scriptBackoffMinutes(scriptFails) * 60_000 : 0;
|
||||
const nextRun = new Date(Math.max(cronNext.getTime(), backoffAt)).toISOString();
|
||||
const nextRun = interval.next().toISOString();
|
||||
const prefix = msg.kind === 'task' ? 'task' : 'msg';
|
||||
const newId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
insertRecurrence(inDb, msg, newId, nextRun);
|
||||
clearRecurrence(inDb, msg.id);
|
||||
@@ -93,7 +41,6 @@ export async function handleRecurrence(inDb: Database.Database, session: Session
|
||||
newId,
|
||||
seriesId: msg.series_id,
|
||||
nextRun,
|
||||
...(scriptFails > 0 && { scriptFails, backoffMin: scriptBackoffMinutes(scriptFails) }),
|
||||
sessionId: session.id,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Approval handlers for self-modification actions.
|
||||
* Guarded handler bodies for self-modification actions.
|
||||
*
|
||||
* The approvals module calls these when an admin clicks Approve on a
|
||||
* pending_approvals row whose action matches. Each handler mutates the
|
||||
* container config in the DB, rebuilds/kills the container as needed,
|
||||
* and writes an on_wake message so the fresh container picks up where
|
||||
* the old one left off.
|
||||
* The delivery registry's guard wrapper runs these only on `allow` — which,
|
||||
* for self-mod, means an approved replay carrying a valid grant (the
|
||||
* baseline holds unconditionally from the container path; see ./guard.ts).
|
||||
* Each body mutates the container config in the DB, rebuilds/kills the
|
||||
* container as needed, and writes an on_wake message so the fresh container
|
||||
* picks up where the old one left off.
|
||||
*
|
||||
* install_packages: update DB + rebuild image + kill container + on_wake.
|
||||
* add_mcp_server: update DB + kill container + on_wake.
|
||||
@@ -17,18 +18,19 @@ import { getSession } from '../../db/sessions.js';
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { log } from '../../log.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import type { ApprovalHandler } from '../approvals/index.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { notifyAgent } from '../approvals/index.js';
|
||||
|
||||
export const applyInstallPackages: ApprovalHandler = async ({ session, payload, userId, notify }) => {
|
||||
export async function applyInstallPackages(payload: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notify('install_packages approved but agent group missing.');
|
||||
notifyAgent(session, 'install_packages approved but agent group missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const configRow = getContainerConfig(agentGroup.id);
|
||||
if (!configRow) {
|
||||
notify('install_packages approved but container config missing.');
|
||||
notifyAgent(session, 'install_packages approved but container config missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload,
|
||||
...((payload.apt as string[] | undefined) || []),
|
||||
...((payload.npm as string[] | undefined) || []),
|
||||
].join(', ');
|
||||
log.info('Package install approved', { agentGroupId: session.agent_group_id, userId });
|
||||
log.info('Package install approved', { agentGroupId: session.agent_group_id });
|
||||
try {
|
||||
await buildAgentGroupImage(session.agent_group_id);
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
@@ -75,23 +77,24 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload,
|
||||
});
|
||||
log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id });
|
||||
} catch (e) {
|
||||
notify(
|
||||
notifyAgent(
|
||||
session,
|
||||
`Packages added to config (${pkgs}) but rebuild failed: ${e instanceof Error ? e.message : String(e)}. Tell the user — an admin will need to retry the install_packages request or inspect the build logs.`,
|
||||
);
|
||||
log.error('Bundled rebuild failed after install approval', { agentGroupId: session.agent_group_id, err: e });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, userId, notify }) => {
|
||||
export async function applyAddMcpServer(payload: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notify('add_mcp_server approved but agent group missing.');
|
||||
notifyAgent(session, 'add_mcp_server approved but agent group missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const configRow = getContainerConfig(agentGroup.id);
|
||||
if (!configRow) {
|
||||
notify('add_mcp_server approved but container config missing.');
|
||||
notifyAgent(session, 'add_mcp_server approved but container config missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,5 +125,5 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use
|
||||
const s = getSession(session.id);
|
||||
if (s) wakeContainer(s);
|
||||
});
|
||||
log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId });
|
||||
};
|
||||
log.info('MCP server add approved', { agentGroupId: session.agent_group_id });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Self-mod guard adapter — the module's catalog entries, composed at the
|
||||
* module edge (imported by ./index.ts).
|
||||
*
|
||||
* The structural baseline is today's behavior verbatim: from the container
|
||||
* path, self-modification is held unconditionally for the agent group's
|
||||
* admin chain. (The equivalent host-side mutations — `ncl groups config
|
||||
* add-package` etc. — are separate catalog actions derived from the command
|
||||
* registry.)
|
||||
*/
|
||||
import { DENY, HOLD, defineGuardedAction, type GuardInput } from '../../guard/index.js';
|
||||
|
||||
function selfModBaseline(label: string) {
|
||||
return (input: GuardInput) => {
|
||||
if (input.actor.kind !== 'agent') {
|
||||
return DENY(`${label} is a container-originated action.`);
|
||||
}
|
||||
return HOLD(`${label} always requires admin approval from the container path`);
|
||||
};
|
||||
}
|
||||
|
||||
export const selfModInstallPackages = defineGuardedAction({
|
||||
action: 'self_mod.install_packages',
|
||||
approvalAction: 'install_packages',
|
||||
baseline: selfModBaseline('install_packages'),
|
||||
});
|
||||
|
||||
export const selfModAddMcpServer = defineGuardedAction({
|
||||
action: 'self_mod.add_mcp_server',
|
||||
approvalAction: 'add_mcp_server',
|
||||
baseline: selfModBaseline('add_mcp_server'),
|
||||
});
|
||||
@@ -2,29 +2,51 @@
|
||||
* Self-modification module — admin-approved container mutations.
|
||||
*
|
||||
* Optional tier. Depends on the approvals default module for the request/
|
||||
* handler plumbing. On install the module registers:
|
||||
* - Two delivery actions (install_packages, add_mcp_server) that validate
|
||||
* input and queue an approval via requestApproval().
|
||||
* - Two matching approval handlers that run on approve and perform the
|
||||
* complete follow-up:
|
||||
* install_packages → update container.json, rebuild image, kill
|
||||
* handler plumbing and on the guard for the decision. On install the module
|
||||
* registers:
|
||||
* - Its guard-catalog entries (./guard.ts): unconditional hold from the
|
||||
* container path.
|
||||
* - Two guard-wrapped delivery actions (install_packages, add_mcp_server):
|
||||
* validation runs as the wrapper's precheck, the hold builders card the
|
||||
* admin, and the handler bodies (./apply.ts) run only on allow — i.e. on
|
||||
* an approved replay:
|
||||
* install_packages → update container_configs, rebuild image, kill
|
||||
* container (next wake respawns on the new image), schedule a
|
||||
* verify-and-report follow-up prompt.
|
||||
* add_mcp_server → update container.json, kill container. No image
|
||||
* add_mcp_server → update container_configs, kill container. No image
|
||||
* rebuild — bun runs TS directly, so the new MCP server is wired
|
||||
* by the next container start.
|
||||
* - Two approval handlers that re-enter the wrapped actions with the
|
||||
* approval row as the grant (one replay semantics — the guard re-checks
|
||||
* the structural baseline live).
|
||||
*
|
||||
* Without this module: the MCP tools in the container still write outbound
|
||||
* system messages with these actions, but delivery logs "Unknown system
|
||||
* action" and drops them. Admin never sees a card; nothing changes.
|
||||
*/
|
||||
import { registerDeliveryAction } from '../../delivery.js';
|
||||
import { registerApprovalHandler } from '../approvals/index.js';
|
||||
import { reenterGuardedDeliveryAction, registerDeliveryAction } from '../../delivery.js';
|
||||
import { notifyAgent, registerApprovalHandler } from '../approvals/index.js';
|
||||
import { applyAddMcpServer, applyInstallPackages } from './apply.js';
|
||||
import { handleAddMcpServer, handleInstallPackages } from './request.js';
|
||||
import { selfModAddMcpServer, selfModInstallPackages } from './guard.js';
|
||||
import {
|
||||
requestAddMcpServerHold,
|
||||
requestInstallPackagesHold,
|
||||
validateAddMcpServer,
|
||||
validateInstallPackages,
|
||||
} from './request.js';
|
||||
|
||||
registerDeliveryAction('install_packages', handleInstallPackages);
|
||||
registerDeliveryAction('add_mcp_server', handleAddMcpServer);
|
||||
registerDeliveryAction('install_packages', applyInstallPackages, {
|
||||
guardAction: selfModInstallPackages,
|
||||
precheck: validateInstallPackages,
|
||||
requestHold: requestInstallPackagesHold,
|
||||
onDeny: (_content, session, reason) => notifyAgent(session, `install_packages denied: ${reason}`),
|
||||
});
|
||||
registerDeliveryAction('add_mcp_server', applyAddMcpServer, {
|
||||
guardAction: selfModAddMcpServer,
|
||||
precheck: validateAddMcpServer,
|
||||
requestHold: requestAddMcpServerHold,
|
||||
onDeny: (_content, session, reason) => notifyAgent(session, `add_mcp_server denied: ${reason}`),
|
||||
});
|
||||
|
||||
registerApprovalHandler('install_packages', applyInstallPackages);
|
||||
registerApprovalHandler('add_mcp_server', applyAddMcpServer);
|
||||
registerApprovalHandler('install_packages', reenterGuardedDeliveryAction('install_packages'));
|
||||
registerApprovalHandler('add_mcp_server', reenterGuardedDeliveryAction('add_mcp_server'));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Delivery-action handlers for agent-initiated self-modification requests.
|
||||
* Validation + hold-request builders for agent-initiated self-modification.
|
||||
*
|
||||
* Two actions the container can write into messages_out (via the self-mod
|
||||
* MCP tools): install_packages, add_mcp_server. Each one validates input
|
||||
* and queues an approval request. The admin's approval triggers the
|
||||
* matching approval handler in ./apply.ts, which also performs the
|
||||
* required follow-up (rebuild+restart for install_packages, restart-only
|
||||
* for add_mcp_server).
|
||||
* MCP tools): install_packages, add_mcp_server. The delivery registry wraps
|
||||
* each one with the guard (see ./guard.ts — unconditional hold from the
|
||||
* container path): validation here runs as the wrapper's precheck, and the
|
||||
* hold builders create the approval card when the guard holds. On approve,
|
||||
* the continuation re-enters the wrapped action and ./apply.ts runs.
|
||||
*
|
||||
* Host-side sanitization for install_packages is defense-in-depth — the MCP
|
||||
* tool validates first. Both layers matter: the DB row carries the payload
|
||||
@@ -17,40 +17,48 @@ import { log } from '../../log.js';
|
||||
import type { Session } from '../../types.js';
|
||||
import { notifyAgent, requestApproval } from '../approvals/index.js';
|
||||
|
||||
export async function handleInstallPackages(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
export function validateInstallPackages(content: Record<string, unknown>, session: Session): boolean {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'install_packages failed: agent group not found.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = (content.reason as string) || '';
|
||||
|
||||
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
|
||||
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
const MAX_PACKAGES = 20;
|
||||
if (apt.length + npm.length === 0) {
|
||||
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (apt.length + npm.length > MAX_PACKAGES) {
|
||||
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const invalidApt = apt.find((p) => !APT_RE.test(p));
|
||||
if (invalidApt) {
|
||||
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
|
||||
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
|
||||
if (invalidNpm) {
|
||||
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
|
||||
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requestInstallPackagesHold(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) return;
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = (content.reason as string) || '';
|
||||
|
||||
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
|
||||
await requestApproval({
|
||||
@@ -63,18 +71,26 @@ export async function handleInstallPackages(content: Record<string, unknown>, se
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAddMcpServer(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
export function validateAddMcpServer(content: Record<string, unknown>, session: Session): boolean {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const serverName = content.name as string;
|
||||
const command = content.command as string;
|
||||
if (!serverName || !command) {
|
||||
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requestAddMcpServerHold(content: Record<string, unknown>, session: Session): Promise<void> {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) return;
|
||||
const serverName = content.name as string;
|
||||
const command = content.command as string;
|
||||
await requestApproval({
|
||||
session,
|
||||
agentName: agentGroup.name,
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
* which triggers module registrations that would otherwise happen before
|
||||
* index.ts's own const initializers have run.
|
||||
*
|
||||
* Keep this file dependency-free (log.js is fine, but nothing from
|
||||
* modules/* or index.ts itself). Any file imported here must not in turn
|
||||
* import from src/index.ts, or the cycle returns.
|
||||
* Keep this file dependency-free (log.js and the guard leaf are fine, but
|
||||
* nothing from modules/* or index.ts itself). Any file imported here must
|
||||
* not in turn import from src/index.ts, or the cycle returns.
|
||||
*
|
||||
* A handler whose click performs a privileged operation registers with a
|
||||
* guard spec: the registry wraps it so the guard's decision stands between
|
||||
* the click and the handler, and the wrapped path is the only path. `claims`
|
||||
* is the handler's own claim test (does this questionId belong to me?) so an
|
||||
* unauthorized click is claimed-and-dropped without stealing other handlers'
|
||||
* responses. The guard argument is not optional — a handler that runs
|
||||
* unguarded must declare so with `unguarded(<reason>)`, at the registration
|
||||
* site, in the diff that adds it.
|
||||
*/
|
||||
import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
export interface ResponsePayload {
|
||||
questionId: string;
|
||||
@@ -23,10 +34,46 @@ export interface ResponsePayload {
|
||||
|
||||
export type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
export interface ResponseGuardSpec {
|
||||
/** Guard action consulted before the handler runs — the defined value, not a name. */
|
||||
action: GuardedAction;
|
||||
/** Would this handler claim the response? (Its own row lookup.) */
|
||||
claims: (payload: ResponsePayload) => boolean;
|
||||
}
|
||||
|
||||
const responseHandlers: ResponseHandler[] = [];
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void {
|
||||
responseHandlers.push(handler);
|
||||
function responseActor(payload: ResponsePayload): GuardActor {
|
||||
if (!payload.userId) return { kind: 'human', userId: '' };
|
||||
const userId = payload.userId.includes(':') ? payload.userId : `${payload.channelType}:${payload.userId}`;
|
||||
return { kind: 'human', userId };
|
||||
}
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void {
|
||||
if (isUnguarded(guardDecl)) {
|
||||
// Explicitly declared unguarded — the carried reason is the reviewable record.
|
||||
responseHandlers.push(handler);
|
||||
return;
|
||||
}
|
||||
const spec = guardDecl;
|
||||
responseHandlers.push(async (payload) => {
|
||||
if (!spec.claims(payload)) return false;
|
||||
const decision = guard(spec.action, {
|
||||
actor: responseActor(payload),
|
||||
payload: { questionId: payload.questionId, value: payload.value },
|
||||
});
|
||||
if (decision.effect !== 'allow') {
|
||||
// Claim the response so it's not unclaimed-logged, but do nothing.
|
||||
log.warn('Response click rejected by guard', {
|
||||
action: spec.action.action,
|
||||
questionId: payload.questionId,
|
||||
userId: payload.userId,
|
||||
reason: decision.reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return handler(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponseHandlers(): readonly ResponseHandler[] {
|
||||
|
||||
+43
-2
@@ -20,6 +20,7 @@
|
||||
import { getChannelAdapter } from './channels/channel-registry.js';
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js';
|
||||
import { recordDroppedMessage } from './db/dropped-messages.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
@@ -117,13 +118,53 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
* Used by modules to capture free-text DM replies during multi-step approval
|
||||
* flows — the permissions module (agent naming during channel registration)
|
||||
* and the approvals module (reject-with-reason capture).
|
||||
*
|
||||
* An interceptor whose capture performs a privileged operation (the
|
||||
* channel-registration name capture creates an agent group + wiring)
|
||||
* registers with a guard spec: the registry wraps it so the guard's decision
|
||||
* stands between the free-text reply and the handler. `claims` returns the
|
||||
* guard consult for events the interceptor would act on (null = not mine,
|
||||
* pass through); a deny consumes the message without acting. The guard
|
||||
* argument is not optional — an interceptor that runs unguarded must declare
|
||||
* so with `unguarded(<reason>)` at the registration site.
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
export interface InterceptorGuardSpec {
|
||||
/** Guard action consulted before the interceptor acts — the defined value, not a name. */
|
||||
action: GuardedAction;
|
||||
/** The guard consult for events this interceptor would act on; null = not mine. */
|
||||
claims: (event: InboundEvent) => { actor: GuardActor; payload: Record<string, unknown> } | null;
|
||||
/** Domain cleanup when the guard denies (e.g. disarm the capture). */
|
||||
onDeny?: (event: InboundEvent) => void;
|
||||
}
|
||||
|
||||
const messageInterceptors: MessageInterceptorFn[] = [];
|
||||
|
||||
export function registerMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptors.push(fn);
|
||||
export function registerMessageInterceptor(
|
||||
fn: MessageInterceptorFn,
|
||||
guardDecl: InterceptorGuardSpec | Unguarded,
|
||||
): void {
|
||||
if (isUnguarded(guardDecl)) {
|
||||
// Explicitly declared unguarded — the carried reason is the reviewable record.
|
||||
messageInterceptors.push(fn);
|
||||
return;
|
||||
}
|
||||
const guardSpec = guardDecl;
|
||||
messageInterceptors.push(async (event) => {
|
||||
const consult = guardSpec.claims(event);
|
||||
if (!consult) return fn(event);
|
||||
const decision = guard(guardSpec.action, { actor: consult.actor, payload: consult.payload });
|
||||
if (decision.effect !== 'allow') {
|
||||
log.warn('Interceptor capture rejected by guard — consuming without acting', {
|
||||
action: guardSpec.action.action,
|
||||
reason: decision.reason,
|
||||
});
|
||||
guardSpec.onDeny?.(event);
|
||||
return true;
|
||||
}
|
||||
return fn(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,11 +22,9 @@ import { ensureContainedInboxDir, isPathInside } from './inbox-safety.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
findSystemSession,
|
||||
findSessionByAgentGroup,
|
||||
findSessionForAgent,
|
||||
getSession,
|
||||
taskThreadId,
|
||||
updateSession,
|
||||
} from './db/sessions.js';
|
||||
import {
|
||||
@@ -122,33 +120,6 @@ export function resolveSession(
|
||||
return { session, created: true };
|
||||
}
|
||||
|
||||
/** Find or create the per-agent-group session used for scheduled tasks. */
|
||||
/** Find or create the isolated session for one task series (thread `system:tasks:<seriesId>`). */
|
||||
export function resolveTaskSession(agentGroupId: string, seriesId: string): { session: Session; created: boolean } {
|
||||
const threadId = taskThreadId(seriesId);
|
||||
const existing = findSystemSession(agentGroupId, threadId);
|
||||
if (existing) return { session: existing, created: false };
|
||||
|
||||
const id = generateId();
|
||||
const session: Session = {
|
||||
id,
|
||||
agent_group_id: agentGroupId,
|
||||
messaging_group_id: null,
|
||||
thread_id: threadId,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
createSession(session);
|
||||
initSessionFolder(agentGroupId, id);
|
||||
log.info('Task session created', { id, agentGroupId, seriesId });
|
||||
|
||||
return { session, created: true };
|
||||
}
|
||||
|
||||
/** Create the session folder and initialize both DBs. */
|
||||
export function initSessionFolder(agentGroupId: string, sessionId: string): void {
|
||||
const dir = sessionDir(agentGroupId, sessionId);
|
||||
@@ -379,16 +350,6 @@ export function openInboundDb(agentGroupId: string, sessionId: string): Database
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Open a session's inbound DB, run `fn`, and always close it. */
|
||||
export function withInboundDb<T>(agentGroupId: string, sessionId: string, fn: (db: Database.Database) => T): T {
|
||||
const db = openInboundDb(agentGroupId, sessionId);
|
||||
try {
|
||||
return fn(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the outbound DB for a session (host reads only). */
|
||||
export function openOutboundDb(agentGroupId: string, sessionId: string): Database.Database {
|
||||
return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId));
|
||||
|
||||
+1
-28
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js';
|
||||
import { formatLocalTime, isValidTimezone, resolveTimezone } from './timezone.js';
|
||||
|
||||
// --- formatLocalTime ---
|
||||
|
||||
@@ -62,30 +62,3 @@ describe('resolveTimezone', () => {
|
||||
expect(resolveTimezone('')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseZonedToUtc', () => {
|
||||
const iso = (s: string, tz: string): string => parseZonedToUtc(s, tz).toISOString();
|
||||
|
||||
it('reads a naive timestamp as wall-clock in a fixed-offset zone', () => {
|
||||
expect(iso('2026-06-20T09:00:00', 'Asia/Tokyo')).toBe('2026-06-20T00:00:00.000Z'); // UTC+9
|
||||
});
|
||||
|
||||
it('applies the correct seasonal offset (DST honored)', () => {
|
||||
// Same wall-clock time, different UTC offset by season — proves the offset
|
||||
// is computed against the zone's rules, not a fixed guess.
|
||||
expect(iso('2026-07-01T12:00:00', 'America/New_York')).toBe('2026-07-01T16:00:00.000Z'); // EDT -4
|
||||
expect(iso('2026-01-01T12:00:00', 'America/New_York')).toBe('2026-01-01T17:00:00.000Z'); // EST -5
|
||||
});
|
||||
|
||||
it('passes a trailing-Z timestamp through unchanged', () => {
|
||||
expect(iso('2026-06-20T09:00:00Z', 'Asia/Tokyo')).toBe('2026-06-20T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes an explicit offset through', () => {
|
||||
expect(iso('2026-06-20T09:00:00+02:00', 'Asia/Tokyo')).toBe('2026-06-20T07:00:00.000Z');
|
||||
});
|
||||
|
||||
it('falls back to UTC for an invalid zone', () => {
|
||||
expect(iso('2026-06-20T09:00:00', 'Not/AZone')).toBe('2026-06-20T09:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,45 +35,3 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
||||
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
||||
* offset info (`Z` or `+-HH:MM`) are passed through to the Date constructor.
|
||||
*/
|
||||
export function parseZonedToUtc(input: string, tz: string): Date {
|
||||
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
|
||||
if (hasOffset) return new Date(input);
|
||||
|
||||
const zone = resolveTimezone(tz);
|
||||
const asIfUtc = new Date(input + 'Z');
|
||||
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
fmt
|
||||
.formatToParts(asIfUtc)
|
||||
.filter((p) => p.type !== 'literal')
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
const hour = parts.hour === '24' ? '00' : parts.hour;
|
||||
const zonedAsUtcMs = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
);
|
||||
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
|
||||
return new Date(asIfUtc.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ export interface Session {
|
||||
// ── Session DB entities ──
|
||||
|
||||
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
|
||||
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
export interface MessageIn {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user