@dsh-enhanced/assistant-delivery 0.1.67 → 0.1.70

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/lib/service.js CHANGED
@@ -446,6 +446,23 @@ const ownerRequestedChangeArgumentBytes = 64 * 1024;
446
446
  const toolApprovalIdentityBytes = 512;
447
447
  /** Bounded owner-ordered linked fallback list for one report route. */
448
448
  const maxLinkedReportFallback = 8;
449
+ /**
450
+ * Host-native tool arguments normalized exactly as Delivery attests them:
451
+ * JSON.parse then JSON.stringify, preserving key order and rejecting any
452
+ * non-object payload. Other components must hash this exact text instead of
453
+ * re-canonicalizing it, otherwise a foreground witness cannot be matched.
454
+ */
455
+ export function normalizeForegroundToolArguments(raw) {
456
+ try {
457
+ const parsed = JSON.parse(raw);
458
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
459
+ return undefined;
460
+ return JSON.stringify(parsed);
461
+ }
462
+ catch {
463
+ return undefined;
464
+ }
465
+ }
449
466
  function inspectUnsettledNativeCall(events, turn, callId, toolName, argumentsJson, argumentBytes = toolApprovalArgumentBytes) {
450
467
  if (!callId || !toolName || Buffer.byteLength(callId) > toolApprovalIdentityBytes
451
468
  || Buffer.byteLength(toolName) > toolApprovalIdentityBytes
@@ -474,16 +491,9 @@ function inspectUnsettledNativeCall(events, turn, callId, toolName, argumentsJso
474
491
  || events.filter(event => event.type === 'step/start' && event.data.turn === turn
475
492
  && event.data.step === call.data.step).length !== 1)
476
493
  return undefined;
477
- let normalizedArguments;
478
- try {
479
- const parsed = JSON.parse(call.data.arguments);
480
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
481
- return undefined;
482
- normalizedArguments = JSON.stringify(parsed);
483
- }
484
- catch {
494
+ const normalizedArguments = normalizeForegroundToolArguments(call.data.arguments);
495
+ if (normalizedArguments === undefined)
485
496
  return undefined;
486
- }
487
497
  const argumentsDigest = createHash('sha256').update(normalizedArguments).digest('hex');
488
498
  if (argumentsDigest !== createHash('sha256').update(argumentsJson).digest('hex'))
489
499
  return undefined;
@@ -2104,10 +2114,53 @@ export class AssistantDeliveryService extends Service {
2104
2114
  completeExecution: (inboxId, input) => this.completeForegroundTaskExecution(inboxId, input),
2105
2115
  recordExecutionModelSelection: (inboxId, input) => this.deliveryStore.recordForegroundExecutionModelSelection({ inboxId, ...input }),
2106
2116
  claimed: (agent, envelope, turn) => this.capturePreferenceTurn(agent, { kind: 'delivery', channel: envelope.channel, account: envelope.account, eventId: envelope.eventId }, turn),
2107
- notifications: binding => this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 50 }).filter(record => record.status === 'accepted'
2108
- // Already-accepted notices stay listed: withdrawal only gates the send attempt.
2117
+ notifications: binding => this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 50 }).filter(record => (record.status === 'accepted' || record.status === 'delivered')
2118
+ // Acknowledged notices stay listed until read; withdrawal only gates the send attempt.
2109
2119
  && record.intent.metadata?.['dsh.native-notice'] === 'v1' && this.#notificationGuard(record, { withdrawal: false }) === undefined)
2110
- .map(record => ({ id: record.id, text: record.intent.text, createdAt: record.createdAt })),
2120
+ .map(record => ({ id: record.id, text: record.intent.text, createdAt: record.createdAt,
2121
+ status: record.status === 'delivered' ? 'delivered' : 'accepted' })),
2122
+ acknowledgeNotices: (binding, ids, status) => {
2123
+ // Validate the whole batch against this binding and the exact send-path
2124
+ // guard before writing anything: one foreign/forged/expired id, drifted
2125
+ // owner/binding/route/policy or withdrawn notice rejects the batch with
2126
+ // zero receipts.
2127
+ const byId = new Map(this.deliveryStore.listOutbox({ bindingId: binding.id, limit: 100 }).map(record => [record.id, record]));
2128
+ const targets = ids.map(id => {
2129
+ const record = byId.get(id);
2130
+ if (record === undefined || record.intent.metadata?.['dsh.native-notice'] !== 'v1'
2131
+ || (record.status !== 'accepted' && record.status !== 'delivered' && record.status !== 'read')
2132
+ || record.providerMessageId === undefined) {
2133
+ throw new Error('assistant-delivery: native notice acknowledgement is invalid');
2134
+ }
2135
+ // Re-run the exact send-path guard: expiry, owner/binding version or
2136
+ // generation drift, route/policy change and withdrawal all fail closed
2137
+ // before any receipt is written. withdrawal:true is required so a
2138
+ // withdrawn notice cannot be acknowledged after it was sent.
2139
+ const refused = this.#nativeNoticeGuard(record.intent, { withdrawal: true });
2140
+ if (refused !== undefined)
2141
+ throw new Error(`assistant-delivery: native notice acknowledgement is ${refused.failureCode}`);
2142
+ return record;
2143
+ });
2144
+ const rank = { accepted: 1, delivered: 2, read: 3 };
2145
+ for (const record of targets) {
2146
+ // A late lower-rank ack is a no-op: it must not append a stale receipt
2147
+ // after read/delivered nor downgrade the outbox state.
2148
+ if (rank[status] <= rank[record.status])
2149
+ continue;
2150
+ try {
2151
+ this.deliveryStore.recordReceipt({ channel: binding.conversation.channel, account: binding.conversation.account,
2152
+ providerMessageId: record.providerMessageId, status, occurredAt: Date.now() });
2153
+ }
2154
+ catch (error) {
2155
+ // The web client re-acks every rendered notice. A repeat with a new
2156
+ // timestamp is an idempotent no-op, not a client error; other
2157
+ // storage failures still propagate.
2158
+ if (!(error instanceof DeliveryStoreError) || error.code !== 'idempotency-conflict')
2159
+ throw error;
2160
+ }
2161
+ }
2162
+ return Object.freeze({ acknowledged: targets.length });
2163
+ },
2111
2164
  }, config);
2112
2165
  this.nativeWebOwner = access;
2113
2166
  this.nativeWebBound = true;
@@ -2198,6 +2251,55 @@ export class AssistantDeliveryService extends Service {
2198
2251
  return Object.freeze({ protocol: 'assistant-delivery/foreground-tool-input/v1',
2199
2252
  attestation, ...page });
2200
2253
  }
2254
+ /**
2255
+ * The live caller's unique configured owner route. A caller never names a
2256
+ * route here: zero matching routes (anonymous, unconfigured, or failed
2257
+ * revalidation) and more than one matching route (ambiguous owner authority)
2258
+ * both fail closed.
2259
+ */
2260
+ ownerRouteForAgent(agent) {
2261
+ if (!this.active || typeof agent !== 'object' || agent === null || agent.session === undefined)
2262
+ return undefined;
2263
+ let resolved;
2264
+ try {
2265
+ for (const authorityId of this.ownerRoutes.keys()) {
2266
+ if (this.validateOwnerAgentForRoute(agent, authorityId) === undefined)
2267
+ continue;
2268
+ if (resolved !== undefined)
2269
+ return undefined;
2270
+ resolved = authorityId;
2271
+ }
2272
+ }
2273
+ catch {
2274
+ return undefined;
2275
+ }
2276
+ return resolved;
2277
+ }
2278
+ /** Host-only proof resolved from the caller Agent's unique live owner route. */
2279
+ inspectOwnerForegroundToolCallForAgent(agent, input) {
2280
+ if (!input || typeof input !== 'object' || Reflect.ownKeys(input).some(key => typeof key !== 'string'
2281
+ || !['callId', 'toolName', 'argumentsJson'].includes(key)))
2282
+ return undefined;
2283
+ const authorityId = this.ownerRouteForAgent(agent);
2284
+ if (authorityId === undefined)
2285
+ return undefined;
2286
+ return this.inspectOwnerForegroundToolCall({ agent, authorityId, callId: input.callId,
2287
+ toolName: input.toolName, argumentsJson: input.argumentsJson });
2288
+ }
2289
+ /** Read-only human-input pages resolved from the caller Agent's unique live
2290
+ * owner route; the caller cannot select an Inbox, route, owner or replacement text. */
2291
+ readOwnerForegroundToolInputForAgent(agent, input) {
2292
+ if (!input || typeof input !== 'object' || Reflect.ownKeys(input).some(key => typeof key !== 'string'
2293
+ || !['callId', 'toolName', 'argumentsJson', 'offset', 'maxBytes'].includes(key)))
2294
+ return undefined;
2295
+ const authorityId = this.ownerRouteForAgent(agent);
2296
+ if (authorityId === undefined)
2297
+ return undefined;
2298
+ return this.readOwnerForegroundToolInput({ agent, authorityId, callId: input.callId,
2299
+ toolName: input.toolName, argumentsJson: input.argumentsJson,
2300
+ ...(input.offset === undefined ? {} : { offset: input.offset }),
2301
+ ...(input.maxBytes === undefined ? {} : { maxBytes: input.maxBytes }) });
2302
+ }
2201
2303
  /** Host-only, read-only source proof for an owner's live memory or wiki tool call. */
2202
2304
  inspectOwnerRequestedChange(execution, input) {
2203
2305
  if (!this.active || !execution || typeof execution !== 'object'
@@ -5268,7 +5370,7 @@ export class AssistantDeliveryService extends Service {
5268
5370
  throw new AssistantDeliveryError('runtime-conflict', 'invalid notification outcome lookup');
5269
5371
  }
5270
5372
  return Object.freeze({ protocol: NOTIFICATION_OUTCOME_FACTS_PROTOCOL,
5271
- facts: this.deliveryStore.attentionAudit.outcomeFactsFor([...new Set(input.outboxIds)]), hasMore: false, nextAfterSequence: null });
5373
+ facts: this.notificationOutcomeFacts([...new Set(input.outboxIds)]), hasMore: false, nextAfterSequence: null });
5272
5374
  }
5273
5375
  const afterSequence = input.afterSequence ?? 0;
5274
5376
  const limit = input.limit ?? 100;
@@ -5276,10 +5378,24 @@ export class AssistantDeliveryService extends Service {
5276
5378
  throw new AssistantDeliveryError('runtime-conflict', 'invalid notification outcome scan');
5277
5379
  }
5278
5380
  const page = this.deliveryStore.attentionAudit.list(afterSequence, limit);
5279
- const facts = this.deliveryStore.attentionAudit.outcomeFactsFor(page.entries.map(entry => entry.outboxId));
5381
+ const facts = this.notificationOutcomeFacts(page.entries.map(entry => entry.outboxId));
5280
5382
  return Object.freeze({ protocol: NOTIFICATION_OUTCOME_FACTS_PROTOCOL, facts, hasMore: page.hasMore,
5281
5383
  nextAfterSequence: page.entries.length === 0 ? null : page.entries.at(-1).sequence });
5282
5384
  }
5385
+ /**
5386
+ * Attach the registered adapter's read-receipt completeness to each fact.
5387
+ * The Delivery store owns transport facts but not adapter capabilities; only
5388
+ * this read boundary can combine them. An undeclared capability stays absent
5389
+ * so older Evaluation classifiers keep their pinned v1 digests.
5390
+ */
5391
+ notificationOutcomeFacts(outboxIds) {
5392
+ return this.deliveryStore.attentionAudit.outcomeFactsFor(outboxIds).map(fact => {
5393
+ const binding = this.deliveryStore.getBinding(fact.bindingId);
5394
+ const readReceipts = binding === undefined ? undefined
5395
+ : this.registry.get(binding.conversation.channel, binding.conversation.account)?.capabilities.readReceipts;
5396
+ return readReceipts === undefined ? fact : Object.freeze({ ...fact, readReceipts });
5397
+ });
5398
+ }
5283
5399
  /**
5284
5400
  * Read-only WP19 stage-2c progress audit page in append order; never exposes
5285
5401
  * progress text. Entries are process-local (`durable: false`), bounded, and