@relaycast/engine 8.15.0 → 9.0.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.
- package/dist/db/migrations/0062_direct_dm_idempotency.sql +20 -0
- package/dist/db/migrations/0063_node_rotation_recovery.sql +13 -0
- package/dist/db/schema.d.ts +200 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +29 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/a2aEgress.d.ts +10 -0
- package/dist/engine/a2aEgress.d.ts.map +1 -1
- package/dist/engine/a2aEgress.js +35 -3
- package/dist/engine/a2aEgress.js.map +1 -1
- package/dist/engine/attachments.d.ts.map +1 -1
- package/dist/engine/attachments.js +5 -3
- package/dist/engine/attachments.js.map +1 -1
- package/dist/engine/dm.d.ts +5 -1
- package/dist/engine/dm.d.ts.map +1 -1
- package/dist/engine/dm.js +131 -20
- package/dist/engine/dm.js.map +1 -1
- package/dist/engine/dmAll.d.ts +6 -0
- package/dist/engine/dmAll.d.ts.map +1 -1
- package/dist/engine/dmAll.js +51 -2
- package/dist/engine/dmAll.js.map +1 -1
- package/dist/engine/inboundWebhook.d.ts +4 -0
- package/dist/engine/inboundWebhook.d.ts.map +1 -1
- package/dist/engine/inboundWebhook.js +5 -0
- package/dist/engine/inboundWebhook.js.map +1 -1
- package/dist/engine/messageMetadata.d.ts +4 -0
- package/dist/engine/messageMetadata.d.ts.map +1 -1
- package/dist/engine/messageMetadata.js +7 -1
- package/dist/engine/messageMetadata.js.map +1 -1
- package/dist/engine/node.d.ts +37 -7
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +107 -19
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/sessionEvent.d.ts.map +1 -1
- package/dist/engine/sessionEvent.js +2 -0
- package/dist/engine/sessionEvent.js.map +1 -1
- package/dist/engine/workspace.d.ts +3 -0
- package/dist/engine/workspace.d.ts.map +1 -1
- package/dist/engine/workspace.js +45 -1
- package/dist/engine/workspace.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/serverTelemetry.d.ts +13 -2
- package/dist/lib/serverTelemetry.d.ts.map +1 -1
- package/dist/lib/serverTelemetry.js +4 -0
- package/dist/lib/serverTelemetry.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +15 -9
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +104 -58
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/routes/a2a.js +6 -1
- package/dist/routes/a2a.js.map +1 -1
- package/dist/routes/agent.js +1 -1
- package/dist/routes/agent.js.map +1 -1
- package/dist/routes/dm.d.ts.map +1 -1
- package/dist/routes/dm.js +70 -42
- package/dist/routes/dm.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +38 -3
- package/dist/routes/node.js.map +1 -1
- package/dist/routes/webhookOutbox.d.ts +1 -0
- package/dist/routes/webhookOutbox.d.ts.map +1 -1
- package/dist/routes/webhookOutbox.js +9 -5
- package/dist/routes/webhookOutbox.js.map +1 -1
- package/dist/routes/workspace.d.ts.map +1 -1
- package/dist/routes/workspace.js +46 -17
- package/dist/routes/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/node.js
CHANGED
|
@@ -16,6 +16,7 @@ import { serializeNodeOp } from './nodeLock.js';
|
|
|
16
16
|
import { completeNodeInvocation, dispatchCapacitySpawn, rescheduleInvocationsForLostNode, rescheduleNodeInvocation, } from './action.js';
|
|
17
17
|
import { emitInvocationCompletionEffects, emitAgentExitedEffects, emitNodeStatusEffects, fleetInvocationId } from './invocationCompletion.js';
|
|
18
18
|
import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
|
|
19
|
+
const NODE_ROTATION_RECOVERY_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
19
20
|
function resolveProviderIdentity(message, connectionId) {
|
|
20
21
|
if (message.provider)
|
|
21
22
|
return message.provider;
|
|
@@ -278,8 +279,8 @@ function normalizeLegacyAdapter(adapter) {
|
|
|
278
279
|
return 'ws.node.v1';
|
|
279
280
|
return adapter;
|
|
280
281
|
}
|
|
281
|
-
export async function createNodeToken(db, workspaceId, data) {
|
|
282
|
-
const token = `nt_live_${randomHex(24)}`;
|
|
282
|
+
export async function createNodeToken(db, workspaceId, data, authorization = {}) {
|
|
283
|
+
const token = authorization.rotationRecovery?.replacementToken ?? `nt_live_${randomHex(24)}`;
|
|
283
284
|
const tokenHash = await sha256Hex(token);
|
|
284
285
|
const name = data.name.startsWith('#') ? data.name.slice(1) : data.name;
|
|
285
286
|
// Reject names that are empty or still #-prefixed after normalization: an
|
|
@@ -292,7 +293,7 @@ export async function createNodeToken(db, workspaceId, data) {
|
|
|
292
293
|
// lookup is only a fallback for callers without a stable id. A name held by
|
|
293
294
|
// a *different* node id is a conflict — mirroring node.register — never a
|
|
294
295
|
// silent rewrite of the other node.
|
|
295
|
-
const existing = await resolveNodeForEnroll(db, workspaceId, data);
|
|
296
|
+
const existing = await resolveNodeForEnroll(db, workspaceId, data, authorization.expectedTokenHash);
|
|
296
297
|
if (data.node_id !== undefined) {
|
|
297
298
|
const nameHolder = await getNodeByName(db, workspaceId, name);
|
|
298
299
|
if (nameHolder && nameHolder.id !== data.node_id) {
|
|
@@ -320,11 +321,42 @@ export async function createNodeToken(db, workspaceId, data) {
|
|
|
320
321
|
throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400);
|
|
321
322
|
}
|
|
322
323
|
if (existing) {
|
|
324
|
+
const hasPresentedTokenProof = authorization.expectedTokenHash !== undefined;
|
|
325
|
+
const authorizedDirectNode = authorization.directAgentId !== undefined
|
|
326
|
+
&& existing.id === directNodeIdForAgent(authorization.directAgentId)
|
|
327
|
+
&& existing.role === 'direct';
|
|
328
|
+
const recovery = authorization.rotationRecovery;
|
|
329
|
+
const isIdempotentRotationReplay = (candidate) => hasPresentedTokenProof
|
|
330
|
+
&& recovery !== undefined
|
|
331
|
+
&& candidate.tokenHash === tokenHash
|
|
332
|
+
&& candidate.previousTokenHash === authorization.expectedTokenHash
|
|
333
|
+
&& candidate.rotationIdempotencyKeyHash === recovery.idempotencyKeyHash
|
|
334
|
+
&& candidate.rotationRequestDigest === recovery.requestDigest
|
|
335
|
+
&& candidate.previousTokenExpiresAt !== null
|
|
336
|
+
&& candidate.previousTokenExpiresAt.getTime() > now.getTime();
|
|
337
|
+
if (isIdempotentRotationReplay(existing)) {
|
|
338
|
+
return { ...publicNode(existing), token };
|
|
339
|
+
}
|
|
340
|
+
if (!hasPresentedTokenProof && !authorizedDirectNode) {
|
|
341
|
+
throw codedError('Node already exists; its current node token is required to rotate or modify it', 'node_token_proof_required', 409);
|
|
342
|
+
}
|
|
323
343
|
const [updated] = await db
|
|
324
344
|
.update(nodes)
|
|
325
345
|
.set({
|
|
326
346
|
name,
|
|
327
347
|
tokenHash,
|
|
348
|
+
previousTokenHash: hasPresentedTokenProof && !authorizedDirectNode && recovery
|
|
349
|
+
? authorization.expectedTokenHash
|
|
350
|
+
: null,
|
|
351
|
+
previousTokenExpiresAt: hasPresentedTokenProof && !authorizedDirectNode && recovery
|
|
352
|
+
? new Date(now.getTime() + NODE_ROTATION_RECOVERY_TTL_MS)
|
|
353
|
+
: null,
|
|
354
|
+
rotationIdempotencyKeyHash: hasPresentedTokenProof && !authorizedDirectNode && recovery
|
|
355
|
+
? recovery.idempotencyKeyHash
|
|
356
|
+
: null,
|
|
357
|
+
rotationRequestDigest: hasPresentedTokenProof && !authorizedDirectNode && recovery
|
|
358
|
+
? recovery.requestDigest
|
|
359
|
+
: null,
|
|
328
360
|
kind,
|
|
329
361
|
role,
|
|
330
362
|
deliveryAdapter,
|
|
@@ -344,8 +376,23 @@ export async function createNodeToken(db, workspaceId, data) {
|
|
|
344
376
|
load: 0,
|
|
345
377
|
loadReported: false,
|
|
346
378
|
})
|
|
347
|
-
|
|
379
|
+
// Token proof is a generation fence, not just a read-time check. If a
|
|
380
|
+
// concurrent rotation changes the credential after `existing` was read,
|
|
381
|
+
// this request must not overwrite the winner with stale authority.
|
|
382
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.id), ...(hasPresentedTokenProof && !authorizedDirectNode
|
|
383
|
+
? [eq(nodes.tokenHash, authorization.expectedTokenHash)]
|
|
384
|
+
: [])))
|
|
348
385
|
.returning();
|
|
386
|
+
if (!updated) {
|
|
387
|
+
// Two identical retries can both read the previous generation before
|
|
388
|
+
// either update runs. If the other request committed this exact keyed
|
|
389
|
+
// rotation, recover its result instead of returning a spurious conflict.
|
|
390
|
+
const winner = recovery ? await getNodeById(db, workspaceId, existing.id) : null;
|
|
391
|
+
if (winner && isIdempotentRotationReplay(winner)) {
|
|
392
|
+
return { ...publicNode(winner), token };
|
|
393
|
+
}
|
|
394
|
+
throw codedError('Node token changed before enrollment completed; retry with the current node token', 'node_token_proof_required', 409);
|
|
395
|
+
}
|
|
349
396
|
return { ...publicNode(updated), token };
|
|
350
397
|
}
|
|
351
398
|
const [created] = await db
|
|
@@ -417,21 +464,18 @@ const MACHINE_MATCH_SCAN_LIMIT = 20;
|
|
|
417
464
|
* separate nodes. Declining the match costs an extra roster row; rejecting the
|
|
418
465
|
* enrollment outright would break a whole fleet booted from one image.
|
|
419
466
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* arbitrarily.
|
|
467
|
+
* Current-token proof selects its exact reusable generation when several
|
|
468
|
+
* brokers share one machine. Without matching proof, the fallback remains
|
|
469
|
+
* oldest-first so a roster converges instead of picking arbitrarily.
|
|
423
470
|
*/
|
|
424
|
-
export async function getBrokerNodeByMachineId(db, workspaceId, machineId) {
|
|
471
|
+
export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expectedTokenHash) {
|
|
425
472
|
// Exclude live rows in SQL so the bound below counts only reusable
|
|
426
473
|
// candidates. Bounding first and filtering after would let a machine with
|
|
427
474
|
// enough live brokers hide its one offline row past the limit, and enrollment
|
|
428
475
|
// would insert a new row instead of reusing it.
|
|
429
476
|
const now = new Date();
|
|
430
477
|
const liveCutoff = new Date(now.getTime() - NODE_LIVENESS_TTL_MS);
|
|
431
|
-
const
|
|
432
|
-
.select()
|
|
433
|
-
.from(nodes)
|
|
434
|
-
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.machineId, machineId), eq(nodes.role, 'broker'),
|
|
478
|
+
const reusableWhere = and(eq(nodes.workspaceId, workspaceId), eq(nodes.machineId, machineId), eq(nodes.role, 'broker'),
|
|
435
479
|
// Mirrors isReusableForMachineMatch in full, so the scan bound below
|
|
436
480
|
// counts only rows that are actually reusable. A stale proof alone is not
|
|
437
481
|
// enough: a broker that registered recently has a fresh lastHeartbeatAt
|
|
@@ -441,7 +485,25 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId) {
|
|
|
441
485
|
// not live — mirrors isNodeLive
|
|
442
486
|
or(ne(nodes.status, 'online'), isNull(nodes.lastHeartbeatAt), lt(nodes.lastHeartbeatAt, liveCutoff)),
|
|
443
487
|
// no future-dated heartbeat — server clock rollback
|
|
444
|
-
or(isNull(nodes.lastHeartbeatAt), lte(nodes.lastHeartbeatAt, now)))
|
|
488
|
+
or(isNull(nodes.lastHeartbeatAt), lte(nodes.lastHeartbeatAt, now)));
|
|
489
|
+
// Several legitimate brokers may share one machine id (VM clones and
|
|
490
|
+
// containers are common). When the caller retains its token, select that
|
|
491
|
+
// exact reusable generation before the oldest-row fallback so one broker's
|
|
492
|
+
// proof is never checked against a sibling row.
|
|
493
|
+
if (expectedTokenHash !== undefined) {
|
|
494
|
+
const [provenCandidate] = await db
|
|
495
|
+
.select()
|
|
496
|
+
.from(nodes)
|
|
497
|
+
.where(and(reusableWhere, eq(nodes.tokenHash, expectedTokenHash)))
|
|
498
|
+
.limit(1);
|
|
499
|
+
if (provenCandidate && isReusableForMachineMatch(provenCandidate)) {
|
|
500
|
+
return provenCandidate;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const candidates = await db
|
|
504
|
+
.select()
|
|
505
|
+
.from(nodes)
|
|
506
|
+
.where(reusableWhere)
|
|
445
507
|
.orderBy(asc(nodes.createdAt), asc(nodes.id))
|
|
446
508
|
.limit(MACHINE_MATCH_SCAN_LIMIT);
|
|
447
509
|
// The SQL above is a pre-filter for `isReusableForMachineMatch`, which is the
|
|
@@ -473,8 +535,10 @@ export function requestedNodeRole(data) {
|
|
|
473
535
|
* The machine_id step is what stops the roster refilling. A host that enrolls
|
|
474
536
|
* without a persisted node_id arrives under a fresh name on every boot, and
|
|
475
537
|
* each of those names used to mint a brand-new row that nothing ever reclaimed.
|
|
476
|
-
* Falling back to the machine's existing broker
|
|
477
|
-
*
|
|
538
|
+
* Falling back to the machine's existing broker lets an authorized holder
|
|
539
|
+
* rotate the row that is already there. createNodeToken separately requires
|
|
540
|
+
* current-node-token proof for enrollment, or authenticated-agent authorization
|
|
541
|
+
* for that agent's canonical direct node, before an existing row can change.
|
|
478
542
|
*
|
|
479
543
|
* node_id and name still win, so a caller that pins either keeps the exact
|
|
480
544
|
* identity it asked for; passing node_id is the way to opt out of machine
|
|
@@ -484,17 +548,41 @@ export function requestedNodeRole(data) {
|
|
|
484
548
|
* getBrokerNodeByMachineId — so a running broker is never adopted by another
|
|
485
549
|
* caller presenting its machine_id.
|
|
486
550
|
*/
|
|
487
|
-
export async function resolveNodeForEnroll(db, workspaceId, data) {
|
|
551
|
+
export async function resolveNodeForEnroll(db, workspaceId, data, expectedTokenHash) {
|
|
488
552
|
if (data.node_id !== undefined) {
|
|
489
|
-
|
|
553
|
+
const byId = await getNodeById(db, workspaceId, data.node_id);
|
|
554
|
+
if (byId)
|
|
555
|
+
return byId;
|
|
556
|
+
// A caller retrying a committed rotation may change the request body by
|
|
557
|
+
// mistake. Resolve the superseded proof below so that request is rejected
|
|
558
|
+
// against the rotated row rather than treated as a new-node enrollment.
|
|
559
|
+
return expectedTokenHash === undefined
|
|
560
|
+
? null
|
|
561
|
+
: getNodeByPreviousTokenHash(db, workspaceId, expectedTokenHash);
|
|
490
562
|
}
|
|
491
563
|
const byName = await getNodeByName(db, workspaceId, data.name);
|
|
492
564
|
if (byName)
|
|
493
565
|
return byName;
|
|
494
566
|
if (data.machine_id !== undefined && requestedNodeRole(data) === 'broker') {
|
|
495
|
-
|
|
567
|
+
const byMachine = await getBrokerNodeByMachineId(db, workspaceId, data.machine_id, expectedTokenHash);
|
|
568
|
+
if (byMachine)
|
|
569
|
+
return byMachine;
|
|
496
570
|
}
|
|
497
|
-
|
|
571
|
+
// Machine reuse clears provenLiveAt, so a committed keyed rotation no
|
|
572
|
+
// longer qualifies for the normal reusable-row lookup. Falling back to the
|
|
573
|
+
// bounded superseded proof makes an exact retry recoverable and a changed
|
|
574
|
+
// body/key fail closed instead of inserting a second node.
|
|
575
|
+
return expectedTokenHash === undefined
|
|
576
|
+
? null
|
|
577
|
+
: getNodeByPreviousTokenHash(db, workspaceId, expectedTokenHash);
|
|
578
|
+
}
|
|
579
|
+
async function getNodeByPreviousTokenHash(db, workspaceId, tokenHash) {
|
|
580
|
+
const [node] = await db
|
|
581
|
+
.select()
|
|
582
|
+
.from(nodes)
|
|
583
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.previousTokenHash, tokenHash)))
|
|
584
|
+
.limit(1);
|
|
585
|
+
return node ?? null;
|
|
498
586
|
}
|
|
499
587
|
export async function registerNode(db, workspaceId, authenticatedNodeId, message, provider) {
|
|
500
588
|
if (message.node_id !== authenticatedNodeId) {
|