agent-relay-server 0.88.3 → 0.89.0

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.
@@ -0,0 +1,303 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { errMessage } from "agent-relay-sdk";
3
+ import { hasScope } from "agent-relay-sdk/types";
4
+ import {
5
+ MAX_BODY_BYTES,
6
+ SCHEDULER_CLAIM_LEASE_MS,
7
+ SCHEDULER_MAX_ACTIVE_TIMERS,
8
+ SCHEDULER_MAX_FAILURES,
9
+ SCHEDULER_MAX_PAYLOAD_BYTES,
10
+ SCHEDULER_MIN_RECURRING_INTERVAL_MS,
11
+ SCHEDULER_RETRY_MS,
12
+ SCHEDULER_TICK_MS,
13
+ } from "../config";
14
+ import {
15
+ ValidationError,
16
+ cancelScheduledTimer,
17
+ claimScheduledTimer,
18
+ completeScheduledTimerRun,
19
+ countActiveScheduledTimers,
20
+ disableScheduledTimer,
21
+ getScheduledTimer,
22
+ insertScheduledTimer,
23
+ listDueScheduledTimerIds,
24
+ listScheduledTimers,
25
+ recordScheduledTimerFailure,
26
+ type ScheduledTimer,
27
+ type TimerScheduleDef,
28
+ type TimerScheduleKind,
29
+ } from "../db";
30
+ import type { ComponentToken, SendMessageInput } from "../types";
31
+ import { ServiceAuthError } from "./errors";
32
+ import type { AuthContext } from "./auth-context";
33
+ import { sendMessageService, validateSendMessageAuthorization } from "./send-message";
34
+
35
+ export interface ScheduleTimerInput {
36
+ target?: string;
37
+ afterMs?: number;
38
+ at?: string;
39
+ everyMs?: number;
40
+ rrule?: string;
41
+ timezone?: string;
42
+ subject?: string;
43
+ body: string;
44
+ payload?: Record<string, unknown>;
45
+ maxRuns?: number;
46
+ expiresAt?: string | number;
47
+ idempotencyKey?: string;
48
+ }
49
+
50
+ export interface RunDueTimersResult {
51
+ claimed: number;
52
+ fired: number;
53
+ failed: number;
54
+ disabled: number;
55
+ messageIds: number[];
56
+ }
57
+
58
+ let schedulerTimer: Timer | undefined;
59
+
60
+ export function startRelayScheduler(): void {
61
+ if (schedulerTimer) return;
62
+ void runDueScheduledTimers().catch((error) => console.warn(`relay scheduler tick failed: ${errMessage(error)}`));
63
+ schedulerTimer = setInterval(() => {
64
+ void runDueScheduledTimers().catch((error) => console.warn(`relay scheduler tick failed: ${errMessage(error)}`));
65
+ }, SCHEDULER_TICK_MS);
66
+ }
67
+
68
+ export function stopRelaySchedulerForTests(): void {
69
+ if (schedulerTimer) clearInterval(schedulerTimer);
70
+ schedulerTimer = undefined;
71
+ }
72
+
73
+ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): ScheduledTimer {
74
+ const now = Date.now();
75
+ const schedule = normalizeSchedule(input, now);
76
+ const target = normalizeTarget(input.target, ctx);
77
+ const createdBy = ctx.callerAgentId ?? ctx.actor.id;
78
+ const deliveryFrom = ctx.callerAgentId ?? "system";
79
+ const body = cleanBody(input.body);
80
+ const payload = cleanPayload(input.payload);
81
+ const maxRuns = cleanPositiveInt(input.maxRuns, "maxRuns");
82
+ const expiresAt = cleanExpiresAt(input.expiresAt, now);
83
+ const idempotencyKey = cleanOptionalString(input.idempotencyKey, "idempotencyKey", 240);
84
+
85
+ if (schedule.kind === "interval") {
86
+ if ((schedule.def.everyMs ?? 0) < SCHEDULER_MIN_RECURRING_INTERVAL_MS) {
87
+ throw new ValidationError(`everyMs must be at least ${SCHEDULER_MIN_RECURRING_INTERVAL_MS}`);
88
+ }
89
+ if (!maxRuns && !expiresAt && !canCreatePersistentTimer(ctx)) {
90
+ throw new ServiceAuthError("recurring timers require maxRuns, expiresAt, or schedule:persistent");
91
+ }
92
+ }
93
+ if (expiresAt !== undefined && expiresAt <= schedule.nextRunAt) {
94
+ throw new ValidationError("expiresAt must be after the first run");
95
+ }
96
+ if (countActiveScheduledTimers(createdBy) >= SCHEDULER_MAX_ACTIVE_TIMERS) {
97
+ throw new ServiceAuthError(`active timer quota exceeded (${SCHEDULER_MAX_ACTIVE_TIMERS})`);
98
+ }
99
+
100
+ validateSendMessageAuthorization({
101
+ from: deliveryFrom,
102
+ to: target,
103
+ body,
104
+ subject: cleanOptionalString(input.subject, "subject", 200),
105
+ payload,
106
+ idempotencyKey: idempotencyKey ? `${idempotencyKey}:probe` : undefined,
107
+ }, ctx);
108
+
109
+ return insertScheduledTimer({
110
+ target,
111
+ createdBy,
112
+ createdByKind: ctx.actor.kind,
113
+ createdByScopes: ctx.scopes,
114
+ createdByConstraints: ctx.constraints,
115
+ deliveryFrom,
116
+ nextRunAt: schedule.nextRunAt,
117
+ scheduleKind: schedule.kind,
118
+ scheduleDef: schedule.def,
119
+ subject: cleanOptionalString(input.subject, "subject", 200),
120
+ body,
121
+ payload,
122
+ maxRuns,
123
+ expiresAt,
124
+ idempotencyKey,
125
+ }, now);
126
+ }
127
+
128
+ export function listTimers(ctx: AuthContext, input: { includeDisabled?: boolean; limit?: number; createdBy?: string } = {}): ScheduledTimer[] {
129
+ const admin = canAdministerTimers(ctx);
130
+ const createdBy = admin ? input.createdBy : (ctx.callerAgentId ?? ctx.actor.id);
131
+ return listScheduledTimers({
132
+ createdBy,
133
+ includeDisabled: input.includeDisabled,
134
+ limit: input.limit,
135
+ });
136
+ }
137
+
138
+ export function cancelTimer(id: string, ctx: AuthContext): ScheduledTimer {
139
+ const timer = getScheduledTimer(id);
140
+ if (!timer) throw new ValidationError(`timer ${id} not found`);
141
+ if (!canAdministerTimers(ctx) && timer.createdBy !== (ctx.callerAgentId ?? ctx.actor.id)) {
142
+ throw new ServiceAuthError("cannot cancel a timer created by another caller");
143
+ }
144
+ return cancelScheduledTimer(id) ?? timer;
145
+ }
146
+
147
+ export async function runDueScheduledTimers(input: { now?: number; limit?: number; owner?: string } = {}): Promise<RunDueTimersResult> {
148
+ const now = input.now ?? Date.now();
149
+ const owner = input.owner ?? `${process.pid}-${randomUUID()}`;
150
+ const ids = listDueScheduledTimerIds(now, input.limit ?? 25);
151
+ const result: RunDueTimersResult = { claimed: 0, fired: 0, failed: 0, disabled: 0, messageIds: [] };
152
+ for (const id of ids) {
153
+ const timer = claimScheduledTimer(id, owner, now, SCHEDULER_CLAIM_LEASE_MS);
154
+ if (!timer) continue;
155
+ result.claimed += 1;
156
+ const fired = fireClaimedTimer(timer, now);
157
+ if (fired.status === "fired") {
158
+ result.fired += 1;
159
+ result.messageIds.push(fired.messageId);
160
+ } else if (fired.status === "disabled") {
161
+ result.disabled += 1;
162
+ } else {
163
+ result.failed += 1;
164
+ }
165
+ }
166
+ return result;
167
+ }
168
+
169
+ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"; messageId: number } | { status: "failed" | "disabled" } {
170
+ if (timer.expiresAt !== undefined && timer.expiresAt <= now) {
171
+ disableScheduledTimer(timer.id, "timer expired before delivery");
172
+ return { status: "disabled" };
173
+ }
174
+ try {
175
+ const runNumber = timer.runCount + 1;
176
+ const result = sendMessageService({
177
+ from: timer.deliveryFrom,
178
+ to: timer.target,
179
+ subject: timer.subject,
180
+ body: timer.body,
181
+ payload: {
182
+ ...timer.payload,
183
+ scheduler: {
184
+ timerId: timer.id,
185
+ correlationId: timer.correlationId,
186
+ run: runNumber,
187
+ },
188
+ },
189
+ meta: {
190
+ scheduler: {
191
+ timerId: timer.id,
192
+ correlationId: timer.correlationId,
193
+ run: runNumber,
194
+ },
195
+ },
196
+ idempotencyKey: timerRunIdempotencyKey(timer, runNumber),
197
+ } satisfies SendMessageInput, persistedAuthContext(timer));
198
+ completeScheduledTimerRun({ timer, messageId: result.message.id, now });
199
+ return { status: "fired", messageId: result.message.id };
200
+ } catch (error) {
201
+ const updated = recordScheduledTimerFailure({
202
+ id: timer.id,
203
+ error: errMessage(error),
204
+ now,
205
+ retryMs: SCHEDULER_RETRY_MS,
206
+ maxFailures: SCHEDULER_MAX_FAILURES,
207
+ });
208
+ return updated?.enabled ? { status: "failed" } : { status: "disabled" };
209
+ }
210
+ }
211
+
212
+ function timerRunIdempotencyKey(timer: ScheduledTimer, runNumber: number): string {
213
+ const base = timer.idempotencyKey ?? `scheduler:${timer.id}`;
214
+ return `${base}:run:${runNumber}`;
215
+ }
216
+
217
+ function persistedAuthContext(timer: ScheduledTimer): AuthContext {
218
+ const component: ComponentToken = {
219
+ sub: timer.createdBy,
220
+ role: timer.createdByKind,
221
+ scope: timer.createdByScopes,
222
+ constraints: timer.createdByConstraints,
223
+ iat: Math.floor(timer.createdAt / 1000),
224
+ };
225
+ return {
226
+ actor: { id: timer.createdBy, kind: "component" },
227
+ scopes: timer.createdByScopes,
228
+ constraints: timer.createdByConstraints,
229
+ callerAgentId: timer.deliveryFrom === "system" ? undefined : timer.deliveryFrom,
230
+ component,
231
+ };
232
+ }
233
+
234
+ function normalizeSchedule(input: ScheduleTimerInput, now: number): { kind: TimerScheduleKind; def: TimerScheduleDef; nextRunAt: number } {
235
+ if (input.rrule || input.timezone) throw new ValidationError("rrule/timezone timers are not supported yet");
236
+ const modes = [input.afterMs !== undefined, input.at !== undefined, input.everyMs !== undefined].filter(Boolean).length;
237
+ if (modes !== 1) throw new ValidationError("provide exactly one of afterMs, at, or everyMs");
238
+ if (input.afterMs !== undefined) {
239
+ const afterMs = cleanPositiveInt(input.afterMs, "afterMs")!;
240
+ return { kind: "once", def: { afterMs }, nextRunAt: now + afterMs };
241
+ }
242
+ if (input.at !== undefined) {
243
+ const atMs = Date.parse(input.at);
244
+ if (!Number.isFinite(atMs) || atMs <= now) throw new ValidationError("at must be a future ISO timestamp");
245
+ return { kind: "once", def: { at: new Date(atMs).toISOString() }, nextRunAt: atMs };
246
+ }
247
+ const everyMs = cleanPositiveInt(input.everyMs, "everyMs")!;
248
+ return { kind: "interval", def: { everyMs }, nextRunAt: now + everyMs };
249
+ }
250
+
251
+ function normalizeTarget(target: string | undefined, ctx: AuthContext): string {
252
+ const raw = cleanOptionalString(target, "target", 200) ?? "me";
253
+ if (raw !== "me") return raw;
254
+ if (!ctx.callerAgentId) throw new ValidationError("target defaults to me, but caller has no single agent identity");
255
+ return ctx.callerAgentId;
256
+ }
257
+
258
+ function cleanBody(value: unknown): string {
259
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError("body required");
260
+ const body = value.trim();
261
+ if (new TextEncoder().encode(body).byteLength > MAX_BODY_BYTES) throw new ValidationError(`body exceeds ${MAX_BODY_BYTES} bytes`);
262
+ return body;
263
+ }
264
+
265
+ function cleanPayload(value: unknown): Record<string, unknown> | undefined {
266
+ if (value === undefined || value === null) return undefined;
267
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError("payload must be an object");
268
+ const payload = value as Record<string, unknown>;
269
+ if (new TextEncoder().encode(JSON.stringify(payload)).byteLength > SCHEDULER_MAX_PAYLOAD_BYTES) {
270
+ throw new ValidationError(`payload exceeds ${SCHEDULER_MAX_PAYLOAD_BYTES} bytes`);
271
+ }
272
+ return payload;
273
+ }
274
+
275
+ function cleanOptionalString(value: unknown, field: string, max: number): string | undefined {
276
+ if (value === undefined || value === null) return undefined;
277
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
278
+ const trimmed = value.trim();
279
+ if (!trimmed) return undefined;
280
+ if (trimmed.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
281
+ return trimmed;
282
+ }
283
+
284
+ function cleanPositiveInt(value: unknown, field: string): number | undefined {
285
+ if (value === undefined || value === null) return undefined;
286
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
287
+ return value;
288
+ }
289
+
290
+ function cleanExpiresAt(value: unknown, now: number): number | undefined {
291
+ if (value === undefined || value === null) return undefined;
292
+ const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
293
+ if (!Number.isFinite(ms) || ms <= now) throw new ValidationError("expiresAt must be a future ISO timestamp or unix timestamp in milliseconds");
294
+ return ms;
295
+ }
296
+
297
+ function canAdministerTimers(ctx: AuthContext): boolean {
298
+ return ctx.actor.kind === "system" || hasScope(ctx.scopes, "system:admin");
299
+ }
300
+
301
+ function canCreatePersistentTimer(ctx: AuthContext): boolean {
302
+ return canAdministerTimers(ctx) || hasScope(ctx.scopes, "schedule:persistent");
303
+ }
@@ -53,6 +53,24 @@ interface SendMessageOptions {
53
53
  integration?: IntegrationTokenConfig | null;
54
54
  }
55
55
 
56
+ export function validateSendMessageAuthorization(
57
+ input: SendMessageInput,
58
+ ctx: AuthContext,
59
+ opts: SendMessageOptions = {},
60
+ ): DeliveryReceipt {
61
+ applyReplyRouting(input);
62
+ if (!input.to) throw new ValidationError("to is required (or provide replyTo to auto-route)");
63
+ // De-singletoned human addressing (#393): collapse a human address to its stored/canonical
64
+ // form so `user:default` is a fully WORKING alias of the legacy bare `'user'` sink.
65
+ if (isHumanAgentId(input.to)) input.to = canonicalHumanAgentId(input.to);
66
+ if (input.from && isHumanAgentId(input.from)) input.from = canonicalHumanAgentId(input.from);
67
+ const reservedSink = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
68
+ input.from = resolveFrom(input, ctx, reservedSink);
69
+ const receipt = resolveSendTarget(input, input.from);
70
+ authorizeSend(input, ctx, opts, reservedSink);
71
+ return receipt;
72
+ }
73
+
56
74
  /** `from` resolves from the token identity: `callerAgentId` (the resolved agent behind the
57
75
  * token) cannot be spoofed and WINS over any wire `from`. The wire value is a fallback only
58
76
  * for identity-less tokens (admin/server/integration/multi-agent).
@@ -157,22 +175,7 @@ export function sendMessageService(
157
175
  ctx: AuthContext,
158
176
  opts: SendMessageOptions = {},
159
177
  ): SendMessageResult {
160
- applyReplyRouting(input);
161
- if (!input.to) throw new ValidationError("to is required (or provide replyTo to auto-route)");
162
- // De-singletoned human addressing (#393): collapse a human address to its stored/canonical
163
- // form so `user:default` is a fully WORKING alias of the legacy bare `'user'` sink — it routes
164
- // to the same agent row + inbox + thread, with zero live-byte movement. Per-user `user:<id>`
165
- // addresses pass through unchanged for the later #388 waves; non-human ids are untouched.
166
- if (isHumanAgentId(input.to)) input.to = canonicalHumanAgentId(input.to);
167
- if (input.from && isHumanAgentId(input.from)) input.from = canonicalHumanAgentId(input.from);
168
- // The reserved-sink observability lane (#284) governs both `from` resolution (wire `from` is
169
- // authoritative — the runner reports an agent's turn) and authorization (drop the recipient
170
- // predicate). Compute once, after reply-routing has settled `to`.
171
- const reservedSink = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
172
- input.from = resolveFrom(input, ctx, reservedSink);
173
- // Resolve BEFORE authorize so both run against the canonical id (kills the HTTP-authorizes-raw
174
- // vs MCP-authorizes-resolved drift).
175
- const receipt = resolveSendTarget(input, input.from);
178
+ const receipt = validateSendMessageAuthorization(input, ctx, opts);
176
179
  // Reply-obligation linking: some real answers are ordinary sends without replyTo/thread linkage.
177
180
  // Channel bridge replies and spawn report-backs both have a single unambiguous open obligation case;
178
181
  // link those at send time so the obligation clears with one message and the Stop hook does not nag
@@ -182,8 +185,6 @@ export function sendMessageService(
182
185
  ?? spawnObligationAnsweredBy(input.from, input.to);
183
186
  if (answered !== null) input.replyTo = answered;
184
187
  }
185
- authorizeSend(input, ctx, opts, reservedSink);
186
-
187
188
  const result = sendMessageWithResult(input);
188
189
  if (result.created) {
189
190
  if (result.message.deliveryStatus === "queued") {
package/src/token-db.ts CHANGED
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
78
78
  name: "Provider Agent",
79
79
  description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
80
80
  role: "provider",
81
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
81
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
82
82
  ttlSeconds: 24 * 60 * 60,
83
83
  builtIn: true,
84
84
  createdBy: "system",
@@ -88,7 +88,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
88
88
  name: "Provider Child Agent",
89
89
  description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
90
90
  role: "provider",
91
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
91
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
92
92
  constraints: { canDelegate: false },
93
93
  ttlSeconds: 2 * 60 * 60,
94
94
  builtIn: true,
@@ -99,7 +99,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
99
99
  name: "Provider Interactive Agent",
100
100
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
101
101
  role: "provider",
102
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
102
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write", "quota:write"],
103
103
  constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
104
104
  ttlSeconds: 30 * 24 * 60 * 60,
105
105
  builtIn: true,
@@ -120,7 +120,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
120
120
  name: "MCP Client",
121
121
  description: "Tool-client access for Relay MCP calls plus message read/send.",
122
122
  role: "mcp",
123
- scope: ["mcp:use", "message:read", "message:send"],
123
+ scope: ["mcp:use", "message:read", "message:send", "schedule:write"],
124
124
  ttlSeconds: 7 * 24 * 60 * 60,
125
125
  builtIn: true,
126
126
  createdBy: "system",
@@ -343,7 +343,7 @@ function ensureBuiltInTokenProfiles(): void {
343
343
  }
344
344
 
345
345
  function defaultScopes(role: string): string[] {
346
- if (role === "provider") return ["agent:read", "agent:write", "message:send", "message:read", "artifact:read"];
346
+ if (role === "provider") return ["agent:read", "agent:write", "message:send", "message:read", "schedule:write", "artifact:read"];
347
347
  if (role === "channel") return ["agent:read", "agent:write", "message:send", "message:read"];
348
348
  if (role === "orchestrator") return ["agent:read", "agent:write", "command:*", "task:read", "task:write"];
349
349
  if (role === "admin" || role === "dashboard") return ["admin:*"];