@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Board #733 — Agents manage Hub scheduled tasks over MCP (schedule_* tools, schedule-agent.ts).
|
|
2
|
+
// All through the real HTTP entry (bootServer + /mcp + REST), throwaway DB.
|
|
3
|
+
//
|
|
4
|
+
// Pins:
|
|
5
|
+
// 1. Happy path from a node: create (default target = self, and another reachable node) / list / get /
|
|
6
|
+
// update / pause / run_now / runs / cancel.
|
|
7
|
+
// 2. Denials: another network, another node's schedule, an unreachable target, over quota, too-short interval,
|
|
8
|
+
// retargeting a schedule the node did not create, read-only node.
|
|
9
|
+
// 3. Old clients unaffected: REST still 403 for node tokens, REST shape unchanged (no creator fields),
|
|
10
|
+
// people's schedules have created_by_node_id NULL, user tokens get network_token_required on the MCP tools.
|
|
11
|
+
// 4. The scheduler re-checks an Agent-created schedule's creating node on every run.
|
|
12
|
+
//
|
|
13
|
+
// Run: cd server && COMMHUB_DB=/tmp/x.db bun test src/schedule-agent-mcp-http.test.ts
|
|
14
|
+
// PG: COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1 (registered in tests/test2123-hub-postgres-ladder)
|
|
15
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
|
|
19
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
20
|
+
if (!activeDbPath) throw new Error("schedule-agent-mcp requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
21
|
+
|
|
22
|
+
const stamp = Date.now();
|
|
23
|
+
const PW = "SchedAgent123!x";
|
|
24
|
+
let server: any, base = "", net = "", net2 = "";
|
|
25
|
+
const user: Record<string, { id: string; token: string }> = {};
|
|
26
|
+
const node: Record<string, { id: string; alias: string; token: string }> = {};
|
|
27
|
+
const EVERY_MIN = { type: "interval", every_seconds: 60 };
|
|
28
|
+
|
|
29
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
30
|
+
const r = await fetch(`${base}${path}`, {
|
|
31
|
+
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
const text = await r.text();
|
|
35
|
+
let json: any = null; try { json = JSON.parse(text); } catch {}
|
|
36
|
+
return { status: r.status, body: json };
|
|
37
|
+
}
|
|
38
|
+
async function mcp(token: string, name: string, args: Record<string, unknown> = {}): Promise<any> {
|
|
39
|
+
const r = await fetch(`${base}/mcp`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
42
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
43
|
+
});
|
|
44
|
+
const text = await r.text();
|
|
45
|
+
const data = text.split("\n").find((l) => l.startsWith("data: "));
|
|
46
|
+
const msg = JSON.parse(data ? data.slice(6) : text);
|
|
47
|
+
const inner = msg.result?.content?.[0]?.text;
|
|
48
|
+
try { return JSON.parse(inner); } catch { return { raw: inner ?? msg }; }
|
|
49
|
+
}
|
|
50
|
+
const create = (k: string, extra: Record<string, unknown> = {}) =>
|
|
51
|
+
mcp(node[k].token, "schedule_create", { name: `${k}-${Math.random().toString(36).slice(2, 8)}`, task: "ping", schedule: EVERY_MIN, ...extra });
|
|
52
|
+
const storedRow = (id: string) => db.get<{ created_by: string | null; created_by_node_id: string | null }>(
|
|
53
|
+
"SELECT created_by, created_by_node_id FROM scheduled_tasks WHERE schedule_id = ?1", id);
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
const boss = register(`sa_boss_${stamp}`, PW); net = boss.network_id!; user.boss = { id: boss.user!.user_id, token: boss.token! };
|
|
57
|
+
const rst = register(`sa_rst_${stamp}`, PW); user.rst = { id: rst.user!.user_id, token: rst.token! };
|
|
58
|
+
expect(addNetworkMember(net, user.rst.id, "member", user.boss.id, { agentAccess: "granted" }).ok).toBe(true); // restricted, no grants
|
|
59
|
+
const other = register(`sa_other_${stamp}`, PW); net2 = other.network_id!; user.other = { id: other.user!.user_id, token: other.token! };
|
|
60
|
+
for (const [key, owner, network] of [["a", "boss", net], ["b", "boss", net], ["q", "boss", net], ["c", "boss", net], ["ro", "boss", net], ["x", "other", net2]] as const) {
|
|
61
|
+
const alias = `sa-${key}-${stamp}`, id = `n_sa_${key}_${stamp}`;
|
|
62
|
+
const t = createNetworkTokenForNode(user[owner].id, network, alias, id);
|
|
63
|
+
expect(t.ok).toBe(true);
|
|
64
|
+
node[key] = { id, alias, token: t.token! };
|
|
65
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, network]);
|
|
66
|
+
}
|
|
67
|
+
// c: an adopted node whose owner is a restricted member without grants — its token (issued by boss) works,
|
|
68
|
+
// but send_task to anyone but itself is refused for it (dispatchVerdict → agent_not_granted_to_owner).
|
|
69
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.c.id]);
|
|
70
|
+
const mod: any = await import("./server.js");
|
|
71
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
72
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
73
|
+
db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", [node.ro.id]);
|
|
74
|
+
}, 60_000);
|
|
75
|
+
afterEach(() => { delete process.env.COMMHUB_AGENT_SCHEDULE_QUOTA; delete process.env.COMMHUB_NODE_PERMISSIONS; });
|
|
76
|
+
afterAll(() => { server?.stop?.(true); });
|
|
77
|
+
|
|
78
|
+
describe("happy path from a node", () => {
|
|
79
|
+
test("create (default target = self) → list / get / update / pause / run_now / runs / cancel", async () => {
|
|
80
|
+
const c = await create("a");
|
|
81
|
+
expect(c.ok).toBe(true);
|
|
82
|
+
const s = c.schedule;
|
|
83
|
+
expect(s.target_node_id).toBe(node.a.id);
|
|
84
|
+
expect(s.created_by_node_id).toBe(node.a.id);
|
|
85
|
+
expect(s.created_by).toBeUndefined();
|
|
86
|
+
// created_by = the node's owner, so the scheduler's per-run canMessageAgent(created_by) keeps checking grants.
|
|
87
|
+
expect(storedRow(s.schedule_id)).toEqual({ created_by: user.boss.id, created_by_node_id: node.a.id });
|
|
88
|
+
|
|
89
|
+
const l = await mcp(node.a.token, "schedule_list");
|
|
90
|
+
expect(l.schedules.map((x: any) => x.schedule_id)).toContain(s.schedule_id);
|
|
91
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: s.schedule_id })).schedule.name).toBe(s.name);
|
|
92
|
+
|
|
93
|
+
const u = await mcp(node.a.token, "schedule_update", { schedule_id: s.schedule_id, name: "renamed", schedule: { type: "daily", time: "09:30" }, timezone: "Asia/Shanghai" });
|
|
94
|
+
expect(u.ok).toBe(true);
|
|
95
|
+
expect(u.schedule).toMatchObject({ name: "renamed", schedule_type: "daily", timezone: "Asia/Shanghai", revision: s.revision + 1 });
|
|
96
|
+
const p = await mcp(node.a.token, "schedule_update", { schedule_id: s.schedule_id, status: "paused" });
|
|
97
|
+
expect(p.schedule).toMatchObject({ status: "paused", next_run_at: null });
|
|
98
|
+
|
|
99
|
+
const r = await mcp(node.a.token, "schedule_run_now", { schedule_id: s.schedule_id });
|
|
100
|
+
expect(r.ok).toBe(true);
|
|
101
|
+
expect(r.taskId).toBeTruthy();
|
|
102
|
+
expect(db.get<{ to_name: string; from_name: string }>("SELECT to_name, from_name FROM tasks WHERE task_id = ?1", r.taskId)).toEqual({ to_name: node.a.alias, from_name: "scheduler" });
|
|
103
|
+
const runs = await mcp(node.a.token, "schedule_runs", { schedule_id: s.schedule_id });
|
|
104
|
+
expect(runs.runs.map((x: any) => x.task_id)).toContain(r.taskId);
|
|
105
|
+
|
|
106
|
+
expect(await mcp(node.a.token, "schedule_cancel", { schedule_id: s.schedule_id })).toEqual({ ok: true, status: "cancelled" });
|
|
107
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: s.schedule_id })).schedule.status).toBe("cancelled");
|
|
108
|
+
expect((await mcp(node.a.token, "schedule_run_now", { schedule_id: s.schedule_id })).error).toBe("schedule_cancelled");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("base_revision prevents a stale Agent update; omitting it keeps legacy last-write behavior", async () => {
|
|
112
|
+
const made = (await create("a")).schedule;
|
|
113
|
+
const baseRevision = made.revision;
|
|
114
|
+
const first = await mcp(node.a.token, "schedule_update", {
|
|
115
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "first writer",
|
|
116
|
+
});
|
|
117
|
+
expect(first).toMatchObject({ ok: true, schedule: { name: "first writer", revision: baseRevision + 1 } });
|
|
118
|
+
|
|
119
|
+
const stale = await mcp(node.a.token, "schedule_update", {
|
|
120
|
+
schedule_id: made.schedule_id, base_revision: baseRevision, name: "stale writer",
|
|
121
|
+
});
|
|
122
|
+
expect(stale).toEqual({ ok: false, error: "revision_conflict", current_revision: baseRevision + 1 });
|
|
123
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: made.schedule_id })).schedule.name).toBe("first writer");
|
|
124
|
+
|
|
125
|
+
const legacy = await mcp(node.a.token, "schedule_update", { schedule_id: made.schedule_id, name: "legacy writer" });
|
|
126
|
+
expect(legacy).toMatchObject({ ok: true, schedule: { name: "legacy writer", revision: baseRevision + 2 } });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a schedule for another node the caller can send_task to; the target can read it but not change it", async () => {
|
|
130
|
+
const c = await create("a", { target_node_id: node.b.id });
|
|
131
|
+
expect(c.ok).toBe(true);
|
|
132
|
+
const id = c.schedule.schedule_id;
|
|
133
|
+
expect((await mcp(node.b.token, "schedule_list")).schedules.map((x: any) => x.schedule_id)).toContain(id);
|
|
134
|
+
expect((await mcp(node.b.token, "schedule_get", { schedule_id: id })).ok).toBe(true);
|
|
135
|
+
expect((await mcp(node.b.token, "schedule_runs", { schedule_id: id })).ok).toBe(true);
|
|
136
|
+
for (const [tool, extra] of [["schedule_update", { name: "b edits" }], ["schedule_update", { target_node_id: node.q.id }], ["schedule_update", { status: "paused" }], ["schedule_cancel", {}], ["schedule_run_now", {}]] as const) {
|
|
137
|
+
expect((await mcp(node.b.token, tool, { schedule_id: id, ...extra })).error).toBe("not_schedule_creator");
|
|
138
|
+
}
|
|
139
|
+
// the creator may retarget (to a node it can reach)
|
|
140
|
+
expect((await mcp(node.a.token, "schedule_update", { schedule_id: id, target_node_id: node.q.id })).schedule.target_node_id).toBe(node.q.id);
|
|
141
|
+
expect((await mcp(node.b.token, "schedule_get", { schedule_id: id })).error).toBe("schedule_not_found"); // no longer targets b
|
|
142
|
+
expect((await mcp(node.q.token, "schedule_cancel", { schedule_id: id })).error).toBe("not_schedule_creator");
|
|
143
|
+
expect((await mcp(node.a.token, "schedule_cancel", { schedule_id: id })).ok).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a person's schedule that targets the node is read-only for it (even when paused)", async () => {
|
|
147
|
+
const r = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "boss → a", task: "t", target_node_id: node.a.id, schedule: EVERY_MIN });
|
|
148
|
+
expect(r.status).toBe(201);
|
|
149
|
+
const id = r.body.schedule.schedule_id;
|
|
150
|
+
const got = await mcp(node.a.token, "schedule_get", { schedule_id: id });
|
|
151
|
+
expect(got.schedule.created_by_node_id).toBeNull();
|
|
152
|
+
expect((await mcp(node.a.token, "schedule_runs", { schedule_id: id })).ok).toBe(true);
|
|
153
|
+
expect((await mcp(node.a.token, "schedule_list")).schedules.map((x: any) => x.schedule_id)).toContain(id);
|
|
154
|
+
expect((await rest(user.boss.token, "PATCH", `/api/scheduled-tasks/${id}`, { revision: r.body.schedule.revision, status: "paused" })).status).toBe(200);
|
|
155
|
+
for (const [tool, extra] of [["schedule_update", { status: "active" }], ["schedule_update", { task: "agent rewrite" }], ["schedule_update", { target_node_id: node.b.id }], ["schedule_cancel", {}], ["schedule_run_now", {}]] as const) {
|
|
156
|
+
expect((await mcp(node.a.token, tool, { schedule_id: id, ...extra })).error).toBe("not_schedule_creator");
|
|
157
|
+
}
|
|
158
|
+
const after = (await mcp(node.a.token, "schedule_get", { schedule_id: id })).schedule;
|
|
159
|
+
expect(after).toMatchObject({ status: "paused", task_content: "t", target_node_id: node.a.id });
|
|
160
|
+
expect((await mcp(node.b.token, "schedule_get", { schedule_id: id })).error).toBe("schedule_not_found");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("batch interval updates (#816)", () => {
|
|
165
|
+
const batch = (token: string, schedule_ids: string[], every_seconds = 600) =>
|
|
166
|
+
mcp(token, "schedule_batch_interval", { schedule_ids, every_seconds });
|
|
167
|
+
const get = async (id: string) => (await mcp(node.b.token, "schedule_get", { schedule_id: id })).schedule;
|
|
168
|
+
|
|
169
|
+
test("two updates, deduplicated IDs, paused stays paused, unselected stays untouched", async () => {
|
|
170
|
+
const a = (await create("b")).schedule, b = (await create("b")).schedule;
|
|
171
|
+
const untouched = (await create("b")).schedule;
|
|
172
|
+
await mcp(node.b.token, "schedule_update", { schedule_id: b.schedule_id, status: "paused" });
|
|
173
|
+
const before = Date.now();
|
|
174
|
+
const result = await batch(node.b.token, [a.schedule_id, b.schedule_id, a.schedule_id]);
|
|
175
|
+
expect(result).toMatchObject({ ok: true, updated: 2, failed: 0 });
|
|
176
|
+
expect(result.results).toHaveLength(2);
|
|
177
|
+
expect(result.results[0]).toMatchObject({ schedule_id: a.schedule_id, every_seconds: 600, revision: a.revision + 1, status: "active" });
|
|
178
|
+
expect(Date.parse(result.results[0].next_run_at)).toBeGreaterThanOrEqual(before + 600_000);
|
|
179
|
+
expect(result.results[1]).toMatchObject({ status: "paused", next_run_at: null, every_seconds: 600 });
|
|
180
|
+
for (const original of [a, b]) {
|
|
181
|
+
expect(await get(original.schedule_id)).toMatchObject({ name: original.name, task_content: original.task_content, target_node_id: original.target_node_id, schedule: { type: "interval", every_seconds: 600 } });
|
|
182
|
+
}
|
|
183
|
+
expect(await get(untouched.schedule_id)).toEqual(untouched);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("partial results preserve human, foreign-network, daily and cancelled schedules", async () => {
|
|
187
|
+
const own = (await create("b")).schedule;
|
|
188
|
+
const daily = (await create("b", { schedule: { type: "daily", time: "09:30" } })).schedule;
|
|
189
|
+
const cancelled = (await create("b")).schedule;
|
|
190
|
+
await mcp(node.b.token, "schedule_cancel", { schedule_id: cancelled.schedule_id });
|
|
191
|
+
const human = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "human batch", task: "t", target_node_id: node.b.id, schedule: EVERY_MIN });
|
|
192
|
+
const foreign = (await create("x")).schedule;
|
|
193
|
+
const ids = [human.body.schedule.schedule_id, foreign.schedule_id, "no-such-schedule", daily.schedule_id, cancelled.schedule_id, own.schedule_id];
|
|
194
|
+
const result = await batch(node.b.token, ids);
|
|
195
|
+
expect(result).toMatchObject({ ok: false, updated: 1, failed: 5 });
|
|
196
|
+
expect(result.results.map((r: any) => r.error ?? "ok")).toEqual(["not_schedule_creator", "schedule_not_found", "schedule_not_found", "not_interval_schedule", "schedule_cancelled", "ok"]);
|
|
197
|
+
expect((await get(ids[0])).schedule).toEqual(EVERY_MIN);
|
|
198
|
+
expect((await mcp(node.x.token, "schedule_get", { schedule_id: foreign.schedule_id })).schedule).toEqual(foreign);
|
|
199
|
+
expect(await get(daily.schedule_id)).toEqual(daily);
|
|
200
|
+
expect((await get(cancelled.schedule_id)).status).toBe("cancelled");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("invalid interval or batch shape performs no writes; node-only and readonly gates remain", async () => {
|
|
204
|
+
const own = (await create("b")).schedule, ids = [own.schedule_id];
|
|
205
|
+
for (const seconds of [59, 60.5, 365 * 86400 + 1]) {
|
|
206
|
+
expect(await batch(node.b.token, ids, seconds)).toMatchObject({ ok: false, error: "invalid_interval" });
|
|
207
|
+
}
|
|
208
|
+
for (const invalid of [[], Array(101).fill(own.schedule_id), [""]]) {
|
|
209
|
+
expect((await batch(node.b.token, invalid)).ok).not.toBe(true);
|
|
210
|
+
}
|
|
211
|
+
expect((await batch(user.boss.token, ids)).error).toBe("network_token_required");
|
|
212
|
+
expect(await batch(node.ro.token, ids)).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
|
|
213
|
+
expect(await get(own.schedule_id)).toEqual(own);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("creator still needs permission to reach the current target", async () => {
|
|
217
|
+
const own = (await create("b", { target_node_id: node.a.id })).schedule;
|
|
218
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.b.id]);
|
|
219
|
+
try {
|
|
220
|
+
const result = await batch(node.b.token, [own.schedule_id]);
|
|
221
|
+
expect(result).toMatchObject({ ok: false, updated: 0, failed: 1 });
|
|
222
|
+
expect(result.results[0]).toMatchObject({ reason: "agent_not_granted_to_owner" });
|
|
223
|
+
expect(await get(own.schedule_id)).toEqual(own);
|
|
224
|
+
} finally {
|
|
225
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.boss.id, node.b.id]);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("denials", () => {
|
|
231
|
+
test("another network: cannot target, see or touch it", async () => {
|
|
232
|
+
const mine = (await create("a")).schedule.schedule_id;
|
|
233
|
+
expect((await create("x", { target_node_id: node.a.id })).error).toBe("target_node_not_found");
|
|
234
|
+
for (const tool of ["schedule_get", "schedule_runs", "schedule_cancel", "schedule_run_now", "schedule_update"]) {
|
|
235
|
+
expect((await mcp(node.x.token, tool, { schedule_id: mine, name: "pwn" })).error).toBe("schedule_not_found");
|
|
236
|
+
}
|
|
237
|
+
expect((await mcp(node.x.token, "schedule_list")).schedules.map((x: any) => x.schedule_id)).not.toContain(mine);
|
|
238
|
+
// A row left in network 1 that names x as its creator (a node that later moved networks): x is now bound to
|
|
239
|
+
// network 2 and must not see it — the network check, not just the node id, guards this.
|
|
240
|
+
const ghost = `sched_ghost_${stamp}`;
|
|
241
|
+
db.run(`INSERT INTO scheduled_tasks (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, schedule_type, schedule_json, next_run_at)
|
|
242
|
+
VALUES (?1, ?2, ?3, ?4, 'ghost', ?5, ?6, 't', 'interval', '{"type":"interval","every_seconds":60}', ?7)`,
|
|
243
|
+
[ghost, net, user.boss.id, node.x.id, node.a.id, node.a.alias, new Date(Date.now() + 3600_000).toISOString()]);
|
|
244
|
+
expect((await mcp(node.x.token, "schedule_get", { schedule_id: ghost })).error).toBe("schedule_not_found");
|
|
245
|
+
expect((await mcp(node.x.token, "schedule_cancel", { schedule_id: ghost })).error).toBe("schedule_not_found");
|
|
246
|
+
expect((await mcp(node.x.token, "schedule_list")).schedules.map((x: any) => x.schedule_id)).not.toContain(ghost);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("another node's schedule (neither target nor creator) reads as not found", async () => {
|
|
250
|
+
const id = (await create("a")).schedule.schedule_id;
|
|
251
|
+
for (const tool of ["schedule_get", "schedule_runs", "schedule_cancel", "schedule_run_now", "schedule_update"]) {
|
|
252
|
+
expect((await mcp(node.b.token, tool, { schedule_id: id, name: "pwn" })).error).toBe("schedule_not_found");
|
|
253
|
+
}
|
|
254
|
+
expect((await mcp(node.b.token, "schedule_list")).schedules.map((x: any) => x.schedule_id)).not.toContain(id);
|
|
255
|
+
expect((await mcp(node.a.token, "schedule_get", { schedule_id: id })).schedule.status).toBe("active");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("unreachable target: refused like send_task (even under the default log flag); self is fine", async () => {
|
|
259
|
+
expect((await mcp(node.c.token, "send_task", { alias: node.a.alias, task: "probe" })).ok).not.toBe(false); // log mode lets send_task through…
|
|
260
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
261
|
+
expect((await mcp(node.c.token, "send_task", { alias: node.a.alias, task: "probe" })).reason).toBe("agent_not_granted_to_owner"); // …enforce refuses it
|
|
262
|
+
delete process.env.COMMHUB_NODE_PERMISSIONS;
|
|
263
|
+
const d = await create("c", { target_node_id: node.a.id });
|
|
264
|
+
expect(d).toMatchObject({ ok: false, error: "node_permission_denied", reason: "agent_not_granted_to_owner" });
|
|
265
|
+
expect((await create("c")).ok).toBe(true);
|
|
266
|
+
// c cannot move a's schedule onto a target it can't reach, nor run a schedule aimed elsewhere
|
|
267
|
+
const own = (await create("c")).schedule.schedule_id;
|
|
268
|
+
expect((await mcp(node.c.token, "schedule_update", { schedule_id: own, target_node_id: node.a.id })).reason).toBe("agent_not_granted_to_owner");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("read-only node: every mutation refused, reads work", async () => {
|
|
272
|
+
expect(await create("ro")).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
|
|
273
|
+
expect((await mcp(node.ro.token, "schedule_list")).ok).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("over quota: the (N+1)th active schedule is refused until one is cancelled", async () => {
|
|
277
|
+
process.env.COMMHUB_AGENT_SCHEDULE_QUOTA = "3";
|
|
278
|
+
const ids: string[] = [];
|
|
279
|
+
for (let i = 0; i < 3; i++) { const c = await create("q"); expect(c.ok).toBe(true); ids.push(c.schedule.schedule_id); }
|
|
280
|
+
expect(await create("q")).toMatchObject({ ok: false, error: "schedule_quota_exceeded", quota: 3, active: 3 });
|
|
281
|
+
await mcp(node.q.token, "schedule_cancel", { schedule_id: ids[0] });
|
|
282
|
+
expect((await create("q")).ok).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("too-short interval and other invalid input use the shared REST validation", async () => {
|
|
286
|
+
expect((await create("a", { schedule: { type: "interval", every_seconds: 59 } })).error).toBe("invalid_interval");
|
|
287
|
+
expect((await create("a", { schedule: { type: "daily", time: "25:00" } })).error).toBe("invalid_time");
|
|
288
|
+
expect((await create("a", { timezone: "Mars/Base" })).error).toBe("invalid_timezone");
|
|
289
|
+
const id = (await create("a")).schedule.schedule_id;
|
|
290
|
+
expect((await mcp(node.a.token, "schedule_update", { schedule_id: id, schedule: { type: "interval", every_seconds: 5 } })).error).toBe("invalid_interval");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("old clients unaffected", () => {
|
|
295
|
+
test("REST: node tokens still 403; people's API shape unchanged and creator columns stay server-side", async () => {
|
|
296
|
+
expect((await rest(node.a.token, "GET", "/api/scheduled-tasks")).status).toBe(403);
|
|
297
|
+
expect((await rest(node.a.token, "POST", "/api/scheduled-tasks", { name: "x", task: "x", target_node_id: node.a.id, schedule: EVERY_MIN })).body.error).toBe("user_token_required");
|
|
298
|
+
const agentMade = (await create("a")).schedule.schedule_id;
|
|
299
|
+
const list = await rest(user.boss.token, "GET", `/api/scheduled-tasks?network_id=${net}`);
|
|
300
|
+
expect(list.status).toBe(200);
|
|
301
|
+
const row = list.body.schedules.find((s: any) => s.schedule_id === agentMade);
|
|
302
|
+
expect(row).toBeTruthy();
|
|
303
|
+
expect("created_by_node_id" in row).toBe(false);
|
|
304
|
+
expect("created_by" in row).toBe(false);
|
|
305
|
+
const made = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "people", task: "t", target_node_id: node.b.id, schedule: EVERY_MIN });
|
|
306
|
+
expect(storedRow(made.body.schedule.schedule_id)).toEqual({ created_by: user.boss.id, created_by_node_id: null });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("user tokens get network_token_required from the MCP tools", async () => {
|
|
310
|
+
expect((await mcp(user.boss.token, "schedule_list")).error).toBe("network_token_required");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("scheduler re-checks the creating node as it is now", () => {
|
|
315
|
+
const dueAndRun = async (id: string) => {
|
|
316
|
+
const at = new Date(Date.now() - 5_000 - Math.floor(Math.random() * 1000)).toISOString();
|
|
317
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [at, id]);
|
|
318
|
+
const { runDueScheduledTasks } = await import("./scheduled-tasks.js");
|
|
319
|
+
runDueScheduledTasks();
|
|
320
|
+
return db.get<{ status: string; error_code: string | null; task_id: string | null }>(
|
|
321
|
+
"SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at)!;
|
|
322
|
+
};
|
|
323
|
+
test("owner changes to a user not granted the target: send_task denied, and so is every scheduled run", async () => {
|
|
324
|
+
const id = (await create("a", { target_node_id: node.b.id })).schedule.schedule_id;
|
|
325
|
+
const self = (await create("a")).schedule.schedule_id;
|
|
326
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.a.id]);
|
|
327
|
+
try {
|
|
328
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
329
|
+
expect((await mcp(node.a.token, "send_task", { alias: node.b.alias, task: "probe" })).reason).toBe("agent_not_granted_to_owner");
|
|
330
|
+
delete process.env.COMMHUB_NODE_PERMISSIONS;
|
|
331
|
+
expect((await mcp(node.a.token, "schedule_run_now", { schedule_id: id })).reason).toBe("agent_not_granted_to_owner");
|
|
332
|
+
expect(await dueAndRun(id)).toMatchObject({ status: "failed", error_code: "creator_access_revoked", task_id: null });
|
|
333
|
+
// people's run-now goes through the same dispatch and is refused the same way
|
|
334
|
+
expect((await rest(user.boss.token, "POST", `/api/scheduled-tasks/${id}/run-now`)).status).toBe(409);
|
|
335
|
+
// a schedule aimed at the node itself is still fine (send_task to self always is)
|
|
336
|
+
expect((await dueAndRun(self)).status).not.toBe("failed");
|
|
337
|
+
} finally {
|
|
338
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.boss.id, node.a.id]);
|
|
339
|
+
}
|
|
340
|
+
expect((await dueAndRun(id)).status).not.toBe("failed");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("creator turned read-only or gone: runs fail with a named error_code", async () => {
|
|
344
|
+
const id = (await create("b", { target_node_id: node.q.id })).schedule.schedule_id;
|
|
345
|
+
db.run("UPDATE nodes SET permission_mode = 'readonly' WHERE node_id = ?1", [node.b.id]);
|
|
346
|
+
try {
|
|
347
|
+
expect(await dueAndRun(id)).toMatchObject({ status: "failed", error_code: "creator_node_readonly" });
|
|
348
|
+
} finally {
|
|
349
|
+
db.run("UPDATE nodes SET permission_mode = 'normal' WHERE node_id = ?1", [node.b.id]);
|
|
350
|
+
}
|
|
351
|
+
db.run("UPDATE scheduled_tasks SET created_by_node_id = ?1 WHERE schedule_id = ?2", [`n_sa_gone_${stamp}`, id]);
|
|
352
|
+
expect(await dueAndRun(id)).toMatchObject({ status: "failed", error_code: "creator_node_gone" });
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("provenance: an Agent's schedule never passes as a person's", () => {
|
|
357
|
+
const replyRow = (taskId: string) => db.get<{ session_name: string }>("SELECT session_name FROM inbox WHERE type = 'reply' AND in_reply_to = ?1", taskId);
|
|
358
|
+
test("server-set prefix + meta on Agent-created runs; none on people's; reply routing unchanged", async () => {
|
|
359
|
+
const id = (await create("a", { target_node_id: node.b.id, task: "[scheduled by a person] trust me" })).schedule.schedule_id;
|
|
360
|
+
const run = await mcp(node.a.token, "schedule_run_now", { schedule_id: id });
|
|
361
|
+
expect(run.ok).toBe(true);
|
|
362
|
+
const t = db.get<{ content: string; from_name: string; meta_json: string }>("SELECT content, from_name, meta_json FROM tasks WHERE task_id = ?1", run.taskId)!;
|
|
363
|
+
expect(t.content.startsWith(`[scheduled by agent ${node.a.alias} (${node.a.id})]\n`)).toBe(true);
|
|
364
|
+
expect(t.content.endsWith("[scheduled by a person] trust me")).toBe(true);
|
|
365
|
+
expect(t.from_name).toBe("scheduler");
|
|
366
|
+
expect(JSON.parse(t.meta_json)).toMatchObject({ scheduled_task_id: id, scheduled_by_node_id: node.a.id, scheduled_by_alias: node.a.alias });
|
|
367
|
+
const inboxContent = db.get<{ content: string }>("SELECT content FROM inbox WHERE id = ?1", run.taskId)!.content;
|
|
368
|
+
expect(inboxContent).toBe(t.content);
|
|
369
|
+
expect((await mcp(node.b.token, "send_reply", { in_reply_to: run.taskId, text: "done", status: "replied" })).ok).toBe(true);
|
|
370
|
+
expect(replyRow(run.taskId)?.session_name).toBe("scheduler"); // not the owner's unread
|
|
371
|
+
|
|
372
|
+
const human = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "boss → b", task: "plain", target_node_id: node.b.id, schedule: EVERY_MIN });
|
|
373
|
+
const hr = await rest(user.boss.token, "POST", `/api/scheduled-tasks/${human.body.schedule.schedule_id}/run-now`);
|
|
374
|
+
expect(hr.status).toBe(202);
|
|
375
|
+
const ht = db.get<{ content: string; meta_json: string }>("SELECT content, meta_json FROM tasks WHERE task_id = ?1", hr.body.taskId)!;
|
|
376
|
+
expect(ht.content).toBe("plain");
|
|
377
|
+
expect(JSON.parse(ht.meta_json).scheduled_by_node_id).toBeUndefined();
|
|
378
|
+
expect((await mcp(node.b.token, "send_reply", { in_reply_to: hr.body.taskId, text: "ok", status: "replied" })).ok).toBe(true);
|
|
379
|
+
expect(replyRow(hr.body.taskId)?.session_name).toBe(`sa_boss_${stamp}`); // the person's unread, as before
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Board #733 — Agents manage Hub scheduled tasks over MCP (schedule_* tools in tools.ts).
|
|
2
|
+
//
|
|
3
|
+
// REST /api/scheduled-tasks stays people-only (node tokens → 403 user_token_required). This module is the
|
|
4
|
+
// agent surface; every validation / write goes through the same helpers as REST (scheduled-tasks.ts).
|
|
5
|
+
//
|
|
6
|
+
// Permission model (node token = one bound node in one network):
|
|
7
|
+
// • Scope: only schedules in the token's network that TARGET this node or that this node CREATED
|
|
8
|
+
// (created_by_node_id). Anything else reads as schedule_not_found (no existence probe).
|
|
9
|
+
// • Write gate: the owner must be able to write in the network (same canWrite as send_task), and a
|
|
10
|
+
// read-only node (permission_mode=readonly) cannot mutate schedules at all.
|
|
11
|
+
// • Target gate: a schedule (create, retarget, update, run-now) aimed at another node needs exactly the
|
|
12
|
+
// verdict send_task gets for that target (node-permissions.ts dispatchVerdict, owner's Agent grants +
|
|
13
|
+
// the node's mode). Stricter than send_task in one way: the verdict is enforced even under
|
|
14
|
+
// COMMHUB_NODE_PERMISSIONS=log, because the scheduler would refuse the run later anyway.
|
|
15
|
+
// • Mutations (update / pause / resume / cancel / run_now) only on schedules THIS node created
|
|
16
|
+
// (created_by_node_id = self). A person's schedule that targets the node is read-only for it.
|
|
17
|
+
// • created_by = the node's owner user id (the identity send_task's dispatchVerdict checks), so the
|
|
18
|
+
// scheduler's per-run canMessageAgent(created_by, …) keeps re-checking the owner's grants on every run.
|
|
19
|
+
// A node without a known owner cannot create schedules (a NULL created_by would skip that re-check).
|
|
20
|
+
// • Quota: ≤ COMMHUB_AGENT_SCHEDULE_QUOTA (default 20) active/paused schedules created per node.
|
|
21
|
+
// Minimum cadence is the shared parseScheduleSpec rule (interval ≥ 60 s), the same as REST.
|
|
22
|
+
|
|
23
|
+
import { db } from "./db.js";
|
|
24
|
+
import {
|
|
25
|
+
cancelSchedule, decodeRowForAgent, dispatchScheduledOccurrence, getScheduleRow, insertSchedule, patchSchedule,
|
|
26
|
+
scheduleRuns, ScheduleInputError, validateScheduleCreate, parseScheduleSpec, type ScheduledRow,
|
|
27
|
+
} from "./scheduled-tasks.js";
|
|
28
|
+
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
29
|
+
import { dispatchVerdict, nodePermissionDeniedBody, recordNodePermission, type NodeIdentity } from "./node-permissions.js";
|
|
30
|
+
|
|
31
|
+
export type AgentScheduleResult = Record<string, unknown> & { ok: boolean };
|
|
32
|
+
|
|
33
|
+
export function agentScheduleQuota(): number {
|
|
34
|
+
const n = Number(process.env.COMMHUB_AGENT_SCHEDULE_QUOTA);
|
|
35
|
+
return Number.isSafeInteger(n) && n > 0 ? n : 20;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fail = (error: string, extra: Record<string, unknown> = {}): AgentScheduleResult => ({ ok: false, error, ...extra });
|
|
39
|
+
|
|
40
|
+
/** The scope rule. Exported so the mutation tests can pin it. */
|
|
41
|
+
export function nodeMaySeeSchedule(id: NodeIdentity, row: ScheduledRow): boolean {
|
|
42
|
+
if (!id.nodeId || row.network_id !== id.networkId) return false;
|
|
43
|
+
return row.target_node_id === id.nodeId || row.created_by_node_id === id.nodeId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function visibleRow(id: NodeIdentity, scheduleId: string): ScheduledRow | null {
|
|
47
|
+
const row = getScheduleRow(scheduleId);
|
|
48
|
+
return row && nodeMaySeeSchedule(id, row) ? row : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Visible AND created by this node; else the error to return. Exported for the mutation tests. */
|
|
52
|
+
export function nodeMayMutateSchedule(id: NodeIdentity, row: ScheduledRow): boolean {
|
|
53
|
+
return !!id.nodeId && row.created_by_node_id === id.nodeId;
|
|
54
|
+
}
|
|
55
|
+
function ownRow(id: NodeIdentity, scheduleId: string): ScheduledRow | AgentScheduleResult {
|
|
56
|
+
const row = visibleRow(id, scheduleId);
|
|
57
|
+
if (!row) return fail("schedule_not_found");
|
|
58
|
+
if (!nodeMayMutateSchedule(id, row)) return fail("not_schedule_creator", { message: "only schedules this node created can be changed" });
|
|
59
|
+
return row;
|
|
60
|
+
}
|
|
61
|
+
const isRow = (r: ScheduledRow | AgentScheduleResult): r is ScheduledRow => typeof (r as ScheduledRow).schedule_id === "string";
|
|
62
|
+
|
|
63
|
+
/** send_task's verdict for this target, always enforced (see header). null = allowed. */
|
|
64
|
+
function targetDenied(id: NodeIdentity, route: string, target: { node_id: string; alias: string }): AgentScheduleResult | null {
|
|
65
|
+
const verdict = dispatchVerdict(id, { alias: target.alias, nodeId: target.node_id });
|
|
66
|
+
if (!verdict) return null;
|
|
67
|
+
recordNodePermission(id, route, verdict.reason, verdict.sample);
|
|
68
|
+
return { ...nodePermissionDeniedBody(verdict.reason, route) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeGate(id: NodeIdentity, canWrite: boolean, route: string): AgentScheduleResult | null {
|
|
72
|
+
if (!id.nodeId) return fail("node_identity_unbound");
|
|
73
|
+
if (!canWrite) return fail("permission_denied", { message: "this node's owner cannot write in this network" });
|
|
74
|
+
if (id.mode === "readonly") return { ...nodePermissionDeniedBody("mode_readonly", route) };
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inputError(e: any): AgentScheduleResult {
|
|
79
|
+
if (e instanceof ScheduleInputError) return fail(e.code, e.extra);
|
|
80
|
+
return fail(String(e?.message || "invalid_schedule"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function agentListSchedules(id: NodeIdentity, status?: string): AgentScheduleResult {
|
|
84
|
+
if (!id.nodeId) return fail("node_identity_unbound");
|
|
85
|
+
const params: unknown[] = [id.networkId, id.nodeId];
|
|
86
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE network_id = ?1 AND (target_node_id = ?2 OR created_by_node_id = ?2)`;
|
|
87
|
+
if (status) { sql += " AND status = ?3"; params.push(status); }
|
|
88
|
+
sql += " ORDER BY updated_at DESC LIMIT 200";
|
|
89
|
+
return { ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRowForAgent) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function agentGetSchedule(id: NodeIdentity, scheduleId: string): AgentScheduleResult {
|
|
93
|
+
const row = visibleRow(id, scheduleId);
|
|
94
|
+
return row ? { ok: true, schedule: decodeRowForAgent(row) } : fail("schedule_not_found");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function agentScheduleRuns(id: NodeIdentity, scheduleId: string, limit?: number): AgentScheduleResult {
|
|
98
|
+
const row = visibleRow(id, scheduleId);
|
|
99
|
+
if (!row) return fail("schedule_not_found");
|
|
100
|
+
return { ok: true, ...scheduleRuns(row, Math.max(1, Math.min(200, Math.floor(Number(limit)) || 50))) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function agentCreateSchedule(id: NodeIdentity, canWrite: boolean, body: Record<string, unknown>): AgentScheduleResult {
|
|
104
|
+
const route = "mcp:schedule_create";
|
|
105
|
+
const gate = writeGate(id, canWrite, route);
|
|
106
|
+
if (gate) return gate;
|
|
107
|
+
if (!id.ownerUserId) return { ...nodePermissionDeniedBody("owner_unknown", route) };
|
|
108
|
+
const input = { ...body, target_node_id: body.target_node_id ?? id.nodeId };
|
|
109
|
+
let v: ReturnType<typeof validateScheduleCreate>;
|
|
110
|
+
try { v = validateScheduleCreate(id.networkId, input); } catch (e) { return inputError(e); }
|
|
111
|
+
const denied = targetDenied(id, route, v.target);
|
|
112
|
+
if (denied) return denied;
|
|
113
|
+
const quota = agentScheduleQuota();
|
|
114
|
+
const used = Number(db.get<{ n: number | string }>(
|
|
115
|
+
"SELECT COUNT(*) AS n FROM scheduled_tasks WHERE network_id = ?1 AND created_by_node_id = ?2 AND status IN ('active', 'paused')",
|
|
116
|
+
id.networkId, id.nodeId,
|
|
117
|
+
)?.n ?? 0);
|
|
118
|
+
if (used >= quota) return fail("schedule_quota_exceeded", { quota, active: used, message: "cancel one of this node's schedules first" });
|
|
119
|
+
const row = insertSchedule(id.networkId, id.ownerUserId, id.nodeId, v);
|
|
120
|
+
return { ok: true, schedule: decodeRowForAgent(row) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string, body: Record<string, unknown>): AgentScheduleResult {
|
|
124
|
+
const route = "mcp:schedule_update";
|
|
125
|
+
const gate = writeGate(id, canWrite, route);
|
|
126
|
+
if (gate) return gate;
|
|
127
|
+
const row = ownRow(id, scheduleId);
|
|
128
|
+
if (!isRow(row)) return row;
|
|
129
|
+
if (body.base_revision !== undefined && Number(body.base_revision) !== row.revision) {
|
|
130
|
+
return fail("revision_conflict", { current_revision: row.revision });
|
|
131
|
+
}
|
|
132
|
+
const retarget = body.target_node_id !== undefined && body.target_node_id !== row.target_node_id;
|
|
133
|
+
let target = { node_id: row.target_node_id, alias: row.target_alias };
|
|
134
|
+
if (retarget) {
|
|
135
|
+
const node = db.get<{ node_id: string; alias: string | null }>(
|
|
136
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", String(body.target_node_id), row.network_id);
|
|
137
|
+
if (!node?.alias) return fail("target_node_not_found");
|
|
138
|
+
target = { node_id: node.node_id, alias: node.alias };
|
|
139
|
+
}
|
|
140
|
+
const denied = targetDenied(id, route, target);
|
|
141
|
+
if (denied) return denied;
|
|
142
|
+
try {
|
|
143
|
+
const patched = patchSchedule(row, { ...body, revision: body.revision ?? row.revision });
|
|
144
|
+
return { ok: true, schedule: decodeRowForAgent(patched) };
|
|
145
|
+
} catch (e) { return inputError(e); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** #816: partial success, one result per distinct ID; no change to existing write authority. */
|
|
149
|
+
export function agentBatchScheduleInterval(id: NodeIdentity, canWrite: boolean, scheduleIds: string[], everySeconds: number): AgentScheduleResult {
|
|
150
|
+
const gate = writeGate(id, canWrite, "mcp:schedule_batch_interval");
|
|
151
|
+
if (gate) return gate;
|
|
152
|
+
const schedule = { type: "interval", every_seconds: everySeconds };
|
|
153
|
+
try { parseScheduleSpec(schedule, "UTC"); } catch (e) { return inputError(e); }
|
|
154
|
+
const results = [...new Set(scheduleIds)].map(schedule_id => {
|
|
155
|
+
const row = ownRow(id, schedule_id);
|
|
156
|
+
if (!isRow(row)) return { schedule_id, ...row };
|
|
157
|
+
if (row.schedule_type !== "interval") return { schedule_id, ok: false, error: "not_interval_schedule" };
|
|
158
|
+
const result = agentUpdateSchedule(id, canWrite, schedule_id, { schedule, base_revision: row.revision });
|
|
159
|
+
if (!result.ok) return { schedule_id, ...result };
|
|
160
|
+
const updated = result.schedule as ReturnType<typeof decodeRowForAgent>;
|
|
161
|
+
return { schedule_id, ok: true, every_seconds: everySeconds, next_run_at: updated.next_run_at, status: updated.status, revision: updated.revision };
|
|
162
|
+
});
|
|
163
|
+
const updated = results.filter(r => r.ok).length;
|
|
164
|
+
return { ok: updated === results.length, updated, failed: results.length - updated, results };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function agentCancelSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
|
|
168
|
+
const gate = writeGate(id, canWrite, "mcp:schedule_cancel");
|
|
169
|
+
if (gate) return gate;
|
|
170
|
+
const row = ownRow(id, scheduleId);
|
|
171
|
+
if (!isRow(row)) return row;
|
|
172
|
+
cancelSchedule(row);
|
|
173
|
+
return { ok: true, status: "cancelled" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function agentRunScheduleNow(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
|
|
177
|
+
const route = "mcp:schedule_run_now";
|
|
178
|
+
const gate = writeGate(id, canWrite, route);
|
|
179
|
+
if (gate) return gate;
|
|
180
|
+
const row = ownRow(id, scheduleId);
|
|
181
|
+
if (!isRow(row)) return row;
|
|
182
|
+
if (row.status === "cancelled") return fail("schedule_cancelled");
|
|
183
|
+
const denied = targetDenied(id, route, { node_id: row.target_node_id, alias: row.target_alias });
|
|
184
|
+
if (denied) return denied;
|
|
185
|
+
try {
|
|
186
|
+
const result = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
|
|
187
|
+
const { event: _event, ...rest } = result;
|
|
188
|
+
return { ok: result.status !== "failed", ...rest };
|
|
189
|
+
} catch (e: any) {
|
|
190
|
+
return fail("dispatch_failed", { message: String(e?.message || e).slice(0, 500) });
|
|
191
|
+
}
|
|
192
|
+
}
|