@zgeoff/atc 2.15.1 → 2.16.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.
@@ -33,6 +33,7 @@ import { buildConfigRevision } from './build-config-revision';
33
33
  import { buildExecutionTargets } from './build-execution-targets';
34
34
  import type { ExecutionTarget } from './build-execution-targets';
35
35
  import { buildFleetEvents } from './build-fleet-events';
36
+ import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
36
37
  import { buildMessageTrailEntry } from './build-message-trail-entry';
37
38
  import { buildReportTrailEntry } from './build-report-trail-entry';
38
39
  import { buildReportView } from './build-report-view';
@@ -51,10 +52,14 @@ import type {
51
52
  SpawnParams,
52
53
  TapClient,
53
54
  } from './daemon-context';
55
+ import { drainInbox } from './drain-inbox';
56
+ import type { InboxSource } from './drain-inbox';
54
57
  import { EffectRemainsError } from './effect-remains-error';
55
58
  import { EventSignal } from './event-signal';
56
59
  import { startHookServer } from './hooks';
57
60
  import { IdempotencyLedger } from './idempotency-ledger';
61
+ import { isOwnHookEvent } from './is-own-hook-event';
62
+ import { isTreeInReach } from './is-tree-in-reach';
58
63
  import { loadTranscriptPage } from './load-transcript-page';
59
64
  import { makeHookRunner } from './make-hook-runner';
60
65
  import type { HookScope } from './make-hook-runner';
@@ -321,8 +326,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
321
326
 
322
327
  // A live session's scope also matches the rows it wrote under an earlier
323
328
  // atc id, through the agent session id a restore carries on. Under an
324
- // access, the scope holds only the sessions on targets the access holds,
325
- // so a session outside it matches nothing, as a session never seen does.
329
+ // access, the scope holds only the atc ids of the sessions whose whole
330
+ // tree is on targets the access holds, so a session outside it matches
331
+ // nothing, as a session never seen does. It holds no agent session id there: a session on
332
+ // another target can resume the same agent session, and its rows would
333
+ // match.
326
334
  const buildEventScope = (
327
335
  sessionID: SessionID | null,
328
336
  access: TargetAccess | null,
@@ -341,19 +349,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
341
349
  }
342
350
 
343
351
  const visible = mgr.sessions.filter(
344
- (x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
352
+ (x) =>
353
+ (sessionID === null || x.id === sessionID) && isTreeInReach(mgr.sessions, x.id, access),
345
354
  );
346
355
 
347
- return {
348
- atcIDs: visible.map((x) => x.id),
349
- agentSessionIDs: visible.flatMap((x) =>
350
- x.agentSessionID === undefined ? [] : [x.agentSessionID],
351
- ),
352
- };
356
+ return { atcIDs: visible.map((x) => x.id), agentSessionIDs: [] };
357
+ };
358
+
359
+ // The sessions that may name a trail row: under an access, only those
360
+ // whose whole tree is on targets it holds.
361
+ const collectNamingDescriptors = (access: TargetAccess | null): SessionDescriptor[] => {
362
+ const reached = new Set(
363
+ mgr.sessions
364
+ .filter((x) => access === null || isTreeInReach(mgr.sessions, x.id, access))
365
+ .map((x) => x.id),
366
+ );
367
+
368
+ return mgr.collectDescriptors().filter((d) => reached.has(d.id));
353
369
  };
354
370
 
355
- // A message belongs to the live session holding its atc id or its agent
356
- // session id, else to the atc id it was sent to.
371
+ // A message belongs to the live session holding its atc id, else to the
372
+ // one holding its agent session id, else to the atc id it was sent to.
357
373
  const readMessageView = async (messageID: MessageID) => {
358
374
  const record = await store.findMessageByID(messageID);
359
375
 
@@ -361,11 +377,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
361
377
  return null;
362
378
  }
363
379
 
364
- const owner = mgr.sessions.find(
365
- (x) =>
366
- x.id === record.atcID ||
367
- (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
368
- );
380
+ const owner =
381
+ mgr.sessions.find((x) => x.id === record.atcID) ??
382
+ mgr.sessions.find(
383
+ (x) => record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID,
384
+ );
369
385
 
370
386
  return {
371
387
  session: owner?.id ?? record.atcID,
@@ -408,50 +424,17 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
408
424
  // the order of their sent times, which the inbox drains by.
409
425
  let lastMessageWrite: Promise<void> = Promise.resolve();
410
426
 
411
- // Hands one pending message to the session's tap, once, and reports whether
412
- // it did. The event goes to the tap connection alone: it never reaches
413
- // other clients, the events socket, or hooks.
414
- const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
415
- const tap = taps.claimDelivery(sessionID, record.id);
416
-
417
- if (tap === null) {
418
- return false;
419
- }
420
-
421
- tap.sendEvent({
422
- v: PROTOCOL_V,
423
- ev: 'InboxMessage',
424
- s: sessionID,
425
- message: record.id,
426
- from: record.from,
427
- text: record.text,
428
- sentAt: record.sentAt,
429
- });
427
+ const inboxSource: InboxSource = {
428
+ taps,
429
+ findLinkedOwner: (sessionID) => {
430
+ const s = mgr.sessions.find((x) => x.id === sessionID);
430
431
 
431
- return true;
432
+ return s === undefined ? null : buildMessageOwner(s);
433
+ },
434
+ collectPendingMessages: (owner) => store.collectPendingMessages(owner),
432
435
  };
433
436
 
434
- // Reads the backlog from the store before sending anything, so a tap's
435
- // ok response is always queued ahead of its first message. It sends one
436
- // unclaimed message per call and the tap's ack calls it again, so the
437
- // backlog never outgrows the connection's outbound queue.
438
- const drainInbox = async (sessionID: SessionID) => {
439
- const s = mgr.sessions.find((x) => x.id === sessionID);
440
-
441
- if (s === undefined) {
442
- return;
443
- }
444
-
445
- try {
446
- const pending = await store.collectPendingMessages(buildMessageOwner(s));
447
-
448
- for (const record of pending) {
449
- if (sendInboxMessage(sessionID, record)) {
450
- return;
451
- }
452
- }
453
- } catch {}
454
- };
437
+ const drainSessionInbox = (sessionID: SessionID) => drainInbox(sessionID, inboxSource);
455
438
 
456
439
  // A report id, which a remote session's reporter gives each report,
457
440
  // makes a resent note land once; a resent answer changes nothing, since
@@ -783,6 +766,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
783
766
  }
784
767
 
785
768
  const before = mgr.sessions.find((s) => s.id === e.atcId);
769
+ const runtime = runtimes.get(e.atcId);
770
+
771
+ // A harness nested inside a session inherits its environment and reports
772
+ // under its id; only the harness atc started may change the session.
773
+ if (
774
+ before !== undefined &&
775
+ runtime !== undefined &&
776
+ !isOwnHookEvent(e.agent, before.agent, runtime.hasAgentHookLines)
777
+ ) {
778
+ return;
779
+ }
780
+
781
+ if (runtime !== undefined && e.agent !== undefined) {
782
+ runtime.hasAgentHookLines = true;
783
+ }
784
+
786
785
  const previousAgentSessionID = before?.agentSessionID;
787
786
  const ev = mgr.applyHook(e);
788
787
 
@@ -795,7 +794,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
795
794
  }
796
795
 
797
796
  const kind = ev?.kind ?? null;
798
- const runtime = runtimes.get(e.atcId);
799
797
  const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
800
798
 
801
799
  if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
@@ -841,8 +839,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
841
839
 
842
840
  return true;
843
841
  },
844
- attachTap: (client, sessionID) => ctx.attachTap(client, sessionID),
845
- ackMessage: (client, sessionID, messageID) => ctx.ackMessage(client, sessionID, messageID),
842
+ attachTap: (client, sessionID) => ctx.attachTap(client, sessionID, null),
843
+ ackMessage: (client, sessionID, messageID) =>
844
+ ctx.ackMessage(client, sessionID, messageID, null),
846
845
  detachTap: (client) => {
847
846
  taps.detachAll(client);
848
847
  },
@@ -858,6 +857,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
858
857
  const startSpawn = async (
859
858
  p: SpawnParams,
860
859
  id: SessionID,
860
+ requireInReach: () => void = () => {},
861
861
  ): Promise<Readonly<Record<string, unknown>>> => {
862
862
  const prepared =
863
863
  p.workspace === null ? null : await materializeSpawnWorkspace(p, id, p.workspace);
@@ -866,7 +866,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
866
866
  const warnings = materialized === null ? [] : materialized.warnings;
867
867
 
868
868
  try {
869
- const session = await startSpawnedSession(p, id, materialized);
869
+ const session = await startSpawnedSession(p, id, materialized, requireInReach);
870
870
 
871
871
  return warnings.length === 0 ? { session } : { session, warnings };
872
872
  } catch (error) {
@@ -922,6 +922,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
922
922
  workspace: SessionWorkspace;
923
923
  withheldEnv: readonly string[];
924
924
  }> | null,
925
+ requireInReach: () => void,
925
926
  ): Promise<SessionDescriptor> => {
926
927
  const s = await mgr.spawn(
927
928
  p.cwd,
@@ -937,6 +938,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
937
938
  id,
938
939
  p.target,
939
940
  materialized,
941
+ requireInReach,
940
942
  );
941
943
 
942
944
  const runtime = runtimes.get(s.id);
@@ -945,7 +947,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
945
947
  runtime.pendingLastUsed = true;
946
948
  }
947
949
 
948
- void store.recordSpawnDir(p.cwd);
950
+ void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
949
951
 
950
952
  return getDescriptor(mgr, s.id);
951
953
  };
@@ -981,7 +983,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
981
983
  // Throws the refusal a fresh spawn to the target gets when the access
982
984
  // does not reach the target, and the identity, a held spawn key recorded
983
985
  // for its session. A key that records none is refused: nothing it holds
984
- // shows where its session ran.
986
+ // shows where its session ran. A session the daemon still holds is
987
+ // refused the same way when the access does not reach its whole tree.
985
988
  const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
986
989
  const bound = record.effectTarget;
987
990
 
@@ -989,11 +992,48 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
989
992
  throw buildTargetForbiddenError(findReplayTarget(record));
990
993
  }
991
994
 
992
- if (!access.canUse(bound)) {
995
+ const session = mgr.sessions.find((x) => x.id === record.effectRef);
996
+
997
+ if (
998
+ !access.canUse(bound) ||
999
+ (session !== undefined && !isTreeInReach(mgr.sessions, session.id, access))
1000
+ ) {
993
1001
  throw buildTargetForbiddenError(bound.target);
994
1002
  }
995
1003
  };
996
1004
 
1005
+ // Throws the refusal of a replay out of reach when a held spawn key's
1006
+ // session is no longer live and the access does not reach the whole tree
1007
+ // its fleet rows hold, as the live check refuses a live tree. A session
1008
+ // with no fleet row has no tree left to check.
1009
+ const requireStoredTreeInReach = async (
1010
+ record: IdempotencyRecord,
1011
+ access: TargetAccess,
1012
+ ): Promise<void> => {
1013
+ if (mgr.sessions.some((x) => x.id === record.effectRef)) {
1014
+ return;
1015
+ }
1016
+
1017
+ const fleet = await store.loadFleet();
1018
+
1019
+ const members = fleet.map((entry) => {
1020
+ const grant = buildGrantFromFleetEntry(entry);
1021
+
1022
+ return {
1023
+ id: entry.sessionID,
1024
+ parent: entry.parent ?? null,
1025
+ target: grant.target,
1026
+ targetIdentity: grant.targetIdentity,
1027
+ };
1028
+ });
1029
+
1030
+ const own = members.find((member) => member.id === record.effectRef);
1031
+
1032
+ if (own !== undefined && !isTreeInReach(members, own.id, access)) {
1033
+ throw buildTargetForbiddenError(record.effectTarget?.target ?? findReplayTarget(record));
1034
+ }
1035
+ };
1036
+
997
1037
  // Each confirm token a forget handed out, by token: the session it
998
1038
  // forgets, when it stops being taken, and whether a forget took it. A
999
1039
  // token stays known for one more lifetime after it expires, so a late
@@ -1071,11 +1111,14 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1071
1111
  // it to the session's tap when one is attached. Only the write itself can
1072
1112
  // reject: every later step swallows its own failure, so a rejection means
1073
1113
  // no message was written.
1114
+ // The caller's check runs again once the writes ahead of this one land,
1115
+ // before anything is written; a failed check writes nothing.
1074
1116
  const writeAcceptedMessage = async (
1075
1117
  sessionID: SessionID,
1076
1118
  from: string,
1077
1119
  text: string,
1078
1120
  id: MessageID,
1121
+ requireWriteInReach: () => void,
1079
1122
  ): Promise<MessageRecord> => {
1080
1123
  const s = mgr.sessions.find((x) => x.id === sessionID);
1081
1124
  const previousWrite = lastMessageWrite;
@@ -1096,6 +1139,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1096
1139
  };
1097
1140
 
1098
1141
  try {
1142
+ // The write boundary: the reach check and the start of the row write
1143
+ // run in one synchronous step, so no wait lets the session's tree
1144
+ // leave the access between the check and the write it allows.
1145
+ requireWriteInReach();
1146
+
1099
1147
  await store.writeMessage(record);
1100
1148
 
1101
1149
  await recordMessageStatus(sessionID, record);
@@ -1103,7 +1151,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1103
1151
  written.resolve();
1104
1152
  }
1105
1153
 
1106
- await drainInbox(sessionID);
1154
+ await drainSessionInbox(sessionID);
1107
1155
 
1108
1156
  return record;
1109
1157
  };
@@ -1148,7 +1196,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1148
1196
  build: opts.build,
1149
1197
  daemonID: store.daemonID,
1150
1198
  collectSessions: () => mgr.collectDescriptors(),
1151
- collectSpawnDirs: () => store.collectSpawnDirs(),
1199
+ collectSpawnDirs: async (access) => {
1200
+ const dirs = await store.collectSpawnDirs();
1201
+
1202
+ const reached = dirs.filter((dir) => access === null || access.canUse(dir.grant));
1203
+
1204
+ return [...new Set(reached.map((dir) => dir.cwd))];
1205
+ },
1152
1206
  collectAgents: () => ({
1153
1207
  daemon: {
1154
1208
  hostname: hostname(),
@@ -1172,8 +1226,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1172
1226
  return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
1173
1227
  },
1174
1228
  findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
1175
- collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
1229
+ canSeeSession: (id, access) => isTreeInReach(mgr.sessions, id, access),
1230
+ isSessionVisible: () => true,
1176
1231
  findPermissionSession: (request) => registry.findSessionID(request),
1232
+ resolveSpawnParent: (id) => {
1233
+ const owner = mgr.sessions.find((x) => x.id === id);
1234
+
1235
+ if (owner === undefined) {
1236
+ return 'missing';
1237
+ }
1238
+
1239
+ return owner.parent ?? owner.id;
1240
+ },
1177
1241
  resolveSpawnTarget: (requested) => {
1178
1242
  const target = requested ?? defaultTarget;
1179
1243
  const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
@@ -1193,8 +1257,22 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1193
1257
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1194
1258
  },
1195
1259
  spawnSession: (plan, keyed, access) => {
1260
+ // Under an access, a spawn under a parent whose tree leaves the access
1261
+ // before the harness starts is refused as a spawn under an unknown
1262
+ // parent.
1263
+ const startInReach = (p: SpawnParams, id: SessionID) =>
1264
+ startSpawn(p, id, () => {
1265
+ if (
1266
+ access !== null &&
1267
+ p.parent !== null &&
1268
+ !isTreeInReach(mgr.sessions, p.parent, access)
1269
+ ) {
1270
+ throw new DaemonError('no_such_session', `no session '${p.parent}'`);
1271
+ }
1272
+ });
1273
+
1196
1274
  if (keyed === null) {
1197
- return startSpawn(plan(), mintSessionID());
1275
+ return startInReach(plan(), mintSessionID());
1198
1276
  }
1199
1277
 
1200
1278
  const effectRef = mintSessionID();
@@ -1203,13 +1281,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1203
1281
  operation: 'session.spawn',
1204
1282
  keyed,
1205
1283
  effectRef,
1206
- start: () => startSpawn(plan(), effectRef),
1284
+ start: () => startInReach(plan(), effectRef),
1207
1285
  settle: () => mgr.writeFleet(),
1208
- replay: (record) => {
1209
- if (access !== null) {
1210
- requireReplayInReach(record, access);
1286
+ replay: async (record) => {
1287
+ if (access === null) {
1288
+ return loadSpawnReplay(record);
1211
1289
  }
1212
1290
 
1291
+ requireReplayInReach(record, access);
1292
+
1293
+ await requireStoredTreeInReach(record, access);
1294
+
1295
+ // The tree may change while the fleet is read, so the live check
1296
+ // runs again in the step that answers.
1297
+ requireReplayInReach(record, access);
1298
+
1213
1299
  return loadSpawnReplay(record);
1214
1300
  },
1215
1301
  findEffectTarget: () => {
@@ -1329,7 +1415,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1329
1415
 
1330
1416
  return 'ok';
1331
1417
  },
1332
- adoptSession: async (id, cols, rows) => {
1418
+ adoptSession: async (id, cols, rows, access) => {
1333
1419
  if (!hasResumableTranscript(mgr, id)) {
1334
1420
  return 'no_transcript';
1335
1421
  }
@@ -1338,7 +1424,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1338
1424
 
1339
1425
  runtime?.stopHeadlessRun();
1340
1426
 
1341
- const adopted = await mgr.adoptTerminal(id, cols, rows);
1427
+ const adopted = await mgr.adoptTerminal(
1428
+ id,
1429
+ cols,
1430
+ rows,
1431
+ () => access === null || isTreeInReach(mgr.sessions, id, access),
1432
+ );
1342
1433
 
1343
1434
  if (adopted === null) {
1344
1435
  return 'missing';
@@ -1496,7 +1587,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1496
1587
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
1497
1588
  restoreFleet: (cols, rows) =>
1498
1589
  restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
1499
- readSessionRecord: async (id) => {
1590
+ readSessionRecord: async (id, access) => {
1500
1591
  const s = mgr.sessions.find((x) => x.id === id);
1501
1592
 
1502
1593
  if (s === undefined) {
@@ -1511,7 +1602,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1511
1602
  const result = s.result ?? null;
1512
1603
  const createdAt = s.createdAt;
1513
1604
 
1514
- const lastEventAt = await store.loadLastActivityAt(s.id, s.agentSessionID);
1605
+ // The agent session id links rows from before atc ids stayed stable,
1606
+ // unless a session on another target resumed the same agent session,
1607
+ // whose rows it would match too. Under an access it links nothing: a
1608
+ // session that shared it may be gone, its rows still in the trail.
1609
+ const shared = mgr.sessions.some(
1610
+ (x) =>
1611
+ x.agentSessionID === s.agentSessionID &&
1612
+ (x.target !== s.target || x.targetIdentity !== s.targetIdentity),
1613
+ );
1614
+
1615
+ const linked = access !== null || shared ? undefined : s.agentSessionID;
1616
+
1617
+ const lastEventAt = await store.loadLastActivityAt(s.id, linked);
1515
1618
 
1516
1619
  return {
1517
1620
  session,
@@ -1547,13 +1650,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1547
1650
  },
1548
1651
  readEvents: async (afterID, limit, waitMs, sessionID, access) => {
1549
1652
  const deadline = Date.now() + waitMs;
1550
- const scope = buildEventScope(sessionID, access);
1551
1653
 
1552
1654
  // A wake for an event the read leaves out (a heartbeat, or another
1553
1655
  // session's event under a session filter) loops back to wait out the
1554
- // rest of the window.
1656
+ // rest of the window. The scope is built again after every await, so
1657
+ // a session whose tree left the access while the read waited matches
1658
+ // nothing, and a read whose scope changed under its query runs again.
1555
1659
  for (;;) {
1556
1660
  const generation = eventSignal.generation;
1661
+ const scope = buildEventScope(sessionID, access);
1557
1662
 
1558
1663
  // A read from a cursor takes one row past the limit to learn whether
1559
1664
  // more follow; the latest events have nothing after them.
@@ -1562,11 +1667,24 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1562
1667
  ? await store.collectLatestEvents(limit, scope)
1563
1668
  : await store.collectEventsAfter(afterID, limit + 1, scope);
1564
1669
 
1670
+ if (
1671
+ access !== null &&
1672
+ JSON.stringify(buildEventScope(sessionID, access)) !== JSON.stringify(scope)
1673
+ ) {
1674
+ continue;
1675
+ }
1676
+
1565
1677
  const remaining = deadline - Date.now();
1566
1678
 
1567
1679
  if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
1680
+ const naming = collectNamingDescriptors(access);
1681
+
1682
+ // Under an access an event takes no alias, so each event keeps the
1683
+ // atc id it was recorded under for the check that sends it.
1684
+ const aliases = access === null ? naming : [];
1685
+
1568
1686
  return {
1569
- events: buildFleetEvents(rows.slice(0, limit), mgr.collectDescriptors()),
1687
+ events: buildFleetEvents(rows.slice(0, limit), naming, aliases),
1570
1688
  more: rows.length > limit,
1571
1689
  };
1572
1690
  }
@@ -1577,9 +1695,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1577
1695
  readReport: async (id, access) => {
1578
1696
  const stored = await store.findReport(id, buildEventScope(null, access));
1579
1697
 
1580
- return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
1698
+ if (stored === null) {
1699
+ return null;
1700
+ }
1701
+
1702
+ const naming = collectNamingDescriptors(access);
1703
+ const aliases = access === null ? naming : [];
1704
+
1705
+ // Under an access a report takes no alias, so a session in reach
1706
+ // never names a report whose own session left the access while the
1707
+ // query waited.
1708
+ return {
1709
+ owner: stored.atcID,
1710
+ view: buildReportView(stored, naming, aliases),
1711
+ };
1581
1712
  },
1582
- writeSessionMessage: async (sessionID, from, text, keyed) => {
1713
+ writeSessionMessage: async (sessionID, from, text, keyed, access) => {
1714
+ // Under an access, a session whose tree leaves the access before the
1715
+ // message is written refuses it as an unknown session does.
1716
+ const requireInReach = () => {
1717
+ if (access !== null && !isTreeInReach(mgr.sessions, sessionID, access)) {
1718
+ throw new MessageRefusedError('missing');
1719
+ }
1720
+ };
1721
+
1583
1722
  if (keyed === null) {
1584
1723
  const refusal = findMessageRefusal(sessionID);
1585
1724
 
@@ -1587,9 +1726,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1587
1726
  return refusal;
1588
1727
  }
1589
1728
 
1590
- const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
1729
+ try {
1730
+ const record = await writeAcceptedMessage(
1731
+ sessionID,
1732
+ from,
1733
+ text,
1734
+ mintMessageID(),
1735
+ requireInReach,
1736
+ );
1737
+
1738
+ return { message: record.id, status: record.status };
1739
+ } catch (error) {
1740
+ if (error instanceof MessageRefusedError) {
1741
+ return error.refusal;
1742
+ }
1591
1743
 
1592
- return { message: record.id, status: record.status };
1744
+ throw error;
1745
+ }
1593
1746
  }
1594
1747
 
1595
1748
  const effectRef = mintMessageID();
@@ -1602,13 +1755,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1602
1755
 
1603
1756
  // A refusal throws, so the claim drops and a retry runs fresh.
1604
1757
  start: async () => {
1758
+ requireInReach();
1759
+
1605
1760
  const refusal = findMessageRefusal(sessionID);
1606
1761
 
1607
1762
  if (refusal !== null) {
1608
1763
  throw new MessageRefusedError(refusal);
1609
1764
  }
1610
1765
 
1611
- const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
1766
+ const record = await writeAcceptedMessage(
1767
+ sessionID,
1768
+ from,
1769
+ text,
1770
+ effectRef,
1771
+ requireInReach,
1772
+ );
1612
1773
 
1613
1774
  return { message: record.id, status: record.status };
1614
1775
  },
@@ -1625,7 +1786,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1625
1786
  throw error;
1626
1787
  }
1627
1788
  },
1628
- attachTap: (client, sessionID) => {
1789
+ attachTap: (client, sessionID, access) => {
1629
1790
  const s = mgr.sessions.find((x) => x.id === sessionID);
1630
1791
 
1631
1792
  if (s === undefined) {
@@ -1636,7 +1797,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1636
1797
  return 'unsupported';
1637
1798
  }
1638
1799
 
1639
- emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
1800
+ emitInboxClosed(taps.attach(sessionID, client, access === null), sessionID, 'replaced');
1640
1801
 
1641
1802
  const runtime = runtimes.get(sessionID);
1642
1803
 
@@ -1644,10 +1805,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1644
1805
  runtime.tapAttached = true;
1645
1806
  }
1646
1807
 
1647
- void drainInbox(sessionID);
1808
+ void drainSessionInbox(sessionID);
1648
1809
 
1649
1810
  return 'ok';
1650
1811
  },
1812
+ detachTap: (client, sessionID) => {
1813
+ if (taps.isTap(sessionID, client)) {
1814
+ taps.removeSession(sessionID);
1815
+ }
1816
+ },
1651
1817
  readMessage: async (messageID, waitMs) => {
1652
1818
  const deadline = Date.now() + waitMs;
1653
1819
  let initialStatus: MessageStatus | null = null;
@@ -1681,18 +1847,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1681
1847
  await eventSignal.waitForNext(generation, remaining);
1682
1848
  }
1683
1849
  },
1684
- ackMessage: async (client, sessionID, messageID) => {
1850
+ ackMessage: async (client, sessionID, messageID, access) => {
1685
1851
  const s = mgr.sessions.find((x) => x.id === sessionID);
1686
1852
 
1687
1853
  if (s === undefined || !taps.isTap(sessionID, client)) {
1688
1854
  return 'not_tapping';
1689
1855
  }
1690
1856
 
1691
- const owner = buildMessageOwner(s);
1857
+ // Under an access, only a message sent to this atc id is the
1858
+ // session's to ack, as with the inbox drain.
1859
+ const owner = access === null ? buildMessageOwner(s) : { atcID: s.id };
1692
1860
 
1693
1861
  const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
1694
1862
 
1695
- void drainInbox(sessionID);
1863
+ void drainSessionInbox(sessionID);
1696
1864
 
1697
1865
  if (delivered !== null) {
1698
1866
  await recordMessageStatus(sessionID, delivered);