@zgeoff/atc 2.15.1 → 2.15.2

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.
@@ -21,6 +21,7 @@ import type { SessionID } from '../shared/session-id';
21
21
  import { toSessionID } from '../shared/to-session-id';
22
22
  import { buildPayloadHash } from './build-payload-hash';
23
23
  import { buildScopedContext } from './build-scoped-context';
24
+ import { buildTargetForbiddenError } from './build-target-forbidden-error';
24
25
  import type { DaemonContext, SpawnParams } from './daemon-context';
25
26
  import type { TranscriptPosition } from './load-transcript-page';
26
27
  import { parseSpawnOverrides } from './parse-spawn-overrides';
@@ -35,6 +36,13 @@ interface RequestScope {
35
36
  // The requests that act on the whole daemon, which only its owner may make.
36
37
  const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
37
38
 
39
+ // What a limited connection is sent for one event, and the sessions that
40
+ // event moved out of its view.
41
+ interface ViewUpdate {
42
+ readonly events: readonly EventMsg[];
43
+ readonly withdrawn: readonly SessionID[];
44
+ }
45
+
38
46
  interface PeerSocket extends SocketWriter {
39
47
  readonly end: () => void;
40
48
  }
@@ -64,9 +72,15 @@ export class DaemonConnection {
64
72
 
65
73
  private access: TargetAccess | null = null;
66
74
 
67
- // The sessions and permission requests this limited connection was shown,
68
- // so the events that end them reach it after the session is gone.
69
- private readonly shown = new Set<string>();
75
+ // The sessions in this limited connection's view, the sessions the daemon
76
+ // holds that the view leaves out, and the permission requests the
77
+ // connection was shown, so the events that end them reach it after the
78
+ // session is gone.
79
+ private readonly shownSessions = new Set<SessionID>();
80
+
81
+ private readonly withheldSessions = new Set<SessionID>();
82
+
83
+ private readonly shownRequests = new Set<string>();
70
84
 
71
85
  constructor(peer: PeerSocket, ctx: DaemonContext) {
72
86
  this.peer = peer;
@@ -83,12 +97,30 @@ export class DaemonConnection {
83
97
  }
84
98
 
85
99
  sendEvent(event: EventMsg): void {
86
- if (!this.helloed || !this.canSeeEvent(event)) {
100
+ if (!this.helloed) {
87
101
  return;
88
102
  }
89
103
 
90
- if (!this.queue.send(encodeMessage(event))) {
91
- this.peer.end();
104
+ const view =
105
+ this.access === null
106
+ ? { events: [event], withdrawn: [] }
107
+ : this.updateView(event, this.access);
108
+
109
+ for (const visible of view.events) {
110
+ if (!this.queue.send(encodeMessage(visible))) {
111
+ this.peer.end();
112
+
113
+ return;
114
+ }
115
+ }
116
+
117
+ // Output and inbox messages of a session that left the view stop with
118
+ // it. The detach runs once the view is settled, since it emits an event
119
+ // of its own.
120
+ for (const id of view.withdrawn) {
121
+ this.desynced.delete(id);
122
+ this.ctx.detachSession(this, id);
123
+ this.ctx.detachTap(this, id);
92
124
  }
93
125
  }
94
126
 
@@ -97,7 +129,7 @@ export class DaemonConnection {
97
129
  // intermediate chunk is never dropped without that resync, because a byte
98
130
  // stream cut mid-escape corrupts the client's terminal state.
99
131
  sendOutput(sessionID: SessionID, event: EventMsg, byteLength: number): void {
100
- if (!this.helloed) {
132
+ if (!this.helloed || !this.canSeeOutput(sessionID)) {
101
133
  return;
102
134
  }
103
135
 
@@ -147,11 +179,21 @@ export class DaemonConnection {
147
179
 
148
180
  for (const [sessionID, dropped] of this.desynced) {
149
181
  this.desynced.delete(sessionID);
150
- this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
151
- void this.ctx.resyncClient(sessionID, this);
182
+
183
+ if (this.canSeeOutput(sessionID)) {
184
+ this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
185
+ void this.ctx.resyncClient(sessionID, this);
186
+ }
152
187
  }
153
188
  }
154
189
 
190
+ // Whether this connection may receive the session's output: always for
191
+ // the daemon's owner, and otherwise only while its view holds the
192
+ // session's whole tree.
193
+ private canSeeOutput(sessionID: SessionID): boolean {
194
+ return this.access === null || this.ctx.canSeeSession(sessionID, this.access);
195
+ }
196
+
155
197
  // false: the connection is beyond recovery and gets closed.
156
198
  private applyLine(line: string): boolean {
157
199
  const decoded = decodeMessage(line);
@@ -218,34 +260,99 @@ export class DaemonConnection {
218
260
  return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
219
261
  }
220
262
 
221
- // Whether this connection may see an event: always for the daemon's
222
- // owner, and otherwise only for an event of a session the connection may
223
- // see or was already shown.
224
- private canSeeEvent(event: EventMsg): boolean {
225
- if (this.access === null) {
226
- return true;
227
- }
228
-
263
+ // Brings this limited connection's view up to date for an event and
264
+ // returns what the connection is sent for it, and the sessions that left
265
+ // the view. A session whose tree moved out of reach leaves the view as a
266
+ // removed session does; one whose tree came back into reach joins it as
267
+ // an added session does. The event itself reaches the connection when its
268
+ // session is in the view, or is gone from the daemon after the connection
269
+ // was shown it.
270
+ private updateView(event: EventMsg, access: TargetAccess): ViewUpdate {
229
271
  const sessionID = findEventSession(event);
230
272
  const request = typeof event['request'] === 'string' ? event['request'] : null;
273
+ const sent: EventMsg[] = [];
274
+ const withdrawn: SessionID[] = [];
275
+
276
+ for (const id of this.shownSessions) {
277
+ if (id !== sessionID && this.isWithheld(id, access)) {
278
+ this.shownSessions.delete(id);
279
+ this.withheldSessions.add(id);
280
+ withdrawn.push(id);
281
+ sent.push({ v: PROTOCOL_V, ev: 'SessionRemoved', s: id });
282
+ }
283
+ }
284
+
285
+ for (const id of this.withheldSessions) {
286
+ if (this.ctx.findSessionGrant(id) === null) {
287
+ this.withheldSessions.delete(id);
288
+ } else if (id !== sessionID && this.ctx.canSeeSession(id, access)) {
289
+ this.withheldSessions.delete(id);
290
+ this.shownSessions.add(id);
291
+ sent.push(...this.buildSessionAdded(id));
292
+ }
293
+ }
231
294
 
232
295
  if (sessionID === null) {
233
- return request !== null && this.shown.has(`request:${request}`);
296
+ if (request !== null && this.shownRequests.has(request)) {
297
+ sent.push(event);
298
+ }
299
+
300
+ return { events: sent, withdrawn };
234
301
  }
235
302
 
236
- const grant = this.ctx.findSessionGrant(sessionID);
303
+ if (this.ctx.canSeeSession(sessionID, access)) {
304
+ if (this.withheldSessions.delete(sessionID) && event.ev !== 'SessionAdded') {
305
+ sent.push(...this.buildSessionAdded(sessionID));
306
+ }
307
+
308
+ this.shownSessions.add(sessionID);
237
309
 
238
- if (grant === null || !this.access.canUse(grant)) {
239
- return this.shown.has(`session:${sessionID}`);
310
+ if (request !== null) {
311
+ this.shownRequests.add(request);
312
+ }
313
+
314
+ sent.push(event);
315
+
316
+ return { events: sent, withdrawn };
317
+ }
318
+
319
+ if (this.isWithheld(sessionID, access)) {
320
+ this.withheldSessions.add(sessionID);
321
+
322
+ if (this.shownSessions.delete(sessionID)) {
323
+ withdrawn.push(sessionID);
324
+ sent.push({ v: PROTOCOL_V, ev: 'SessionRemoved', s: sessionID });
325
+ }
326
+
327
+ return { events: sent, withdrawn };
240
328
  }
241
329
 
242
- this.shown.add(`session:${sessionID}`);
330
+ // The session is gone from the daemon.
331
+ this.withheldSessions.delete(sessionID);
243
332
 
244
- if (request !== null) {
245
- this.shown.add(`request:${request}`);
333
+ if (this.shownSessions.has(sessionID)) {
334
+ sent.push(event);
246
335
  }
247
336
 
248
- return true;
337
+ if (event.ev === 'SessionRemoved') {
338
+ this.shownSessions.delete(sessionID);
339
+ }
340
+
341
+ return { events: sent, withdrawn };
342
+ }
343
+
344
+ // Whether the daemon holds the session but the access does not reach its
345
+ // whole tree.
346
+ private isWithheld(id: SessionID, access: TargetAccess): boolean {
347
+ return this.ctx.findSessionGrant(id) !== null && !this.ctx.canSeeSession(id, access);
348
+ }
349
+
350
+ // The event that adds the session to a view, or none when the daemon no
351
+ // longer lists it.
352
+ private buildSessionAdded(id: SessionID): EventMsg[] {
353
+ const session = this.ctx.collectSessions().find((x) => x.id === id);
354
+
355
+ return session === undefined ? [] : [{ v: PROTOCOL_V, ev: 'SessionAdded', session }];
249
356
  }
250
357
 
251
358
  // A store query that rejects must end one request, never the daemon: a
@@ -289,12 +396,16 @@ export class DaemonConnection {
289
396
  return;
290
397
  }
291
398
  case 'dirs.list': {
292
- this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
399
+ this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs(null) });
293
400
 
294
401
  return;
295
402
  }
296
403
  case 'fleet.list': {
297
- this.sendOk(req.id, { fleet: await ctx.collectFleet() });
404
+ const fleet = await ctx.collectFleet();
405
+
406
+ this.sendOk(req.id, {
407
+ fleet: fleet.filter((entry) => ctx.isSessionVisible(entry.sessionID)),
408
+ });
298
409
 
299
410
  return;
300
411
  }
@@ -356,7 +467,7 @@ export class DaemonConnection {
356
467
 
357
468
  const id = parsed.data.session;
358
469
 
359
- const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
470
+ const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken);
360
471
 
361
472
  if (forgotten === 'missing') {
362
473
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -399,7 +510,7 @@ export class DaemonConnection {
399
510
 
400
511
  const screen = await ctx.readSessionScreen(id);
401
512
 
402
- if (screen === 'missing') {
513
+ if (screen === 'missing' || !ctx.isSessionVisible(id)) {
403
514
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
404
515
  } else if (screen === 'no_screen') {
405
516
  this.sendErr(req.id, 'session_dead', `session '${id}' has no captured screen`);
@@ -452,7 +563,12 @@ export class DaemonConnection {
452
563
 
453
564
  const sessionID = parsed.data.session;
454
565
 
455
- const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
566
+ const adoptResult = await ctx.adoptSession(
567
+ sessionID,
568
+ parsed.data.cols,
569
+ parsed.data.rows,
570
+ null,
571
+ );
456
572
 
457
573
  if (adoptResult === 'ok') {
458
574
  this.sendOk(req.id, {});
@@ -616,7 +732,7 @@ export class DaemonConnection {
616
732
  // Checked before the spawn starts, so a target that cannot take a
617
733
  // workspace refuses it before any git command runs.
618
734
  if (data.workspace !== undefined) {
619
- this.ctx.requireWorkspaceTarget(target);
735
+ ctx.requireWorkspaceTarget(target);
620
736
  }
621
737
 
622
738
  // A workspace is materialized at cwd on the target, which only an
@@ -628,15 +744,13 @@ export class DaemonConnection {
628
744
  let parent: SessionID | null = null;
629
745
 
630
746
  if (data.parent !== undefined) {
631
- const owner = ctx.collectSessions().find((s) => s.id === data.parent);
747
+ const resolved = ctx.resolveSpawnParent(data.parent);
632
748
 
633
- if (owner === undefined) {
749
+ if (resolved === 'missing') {
634
750
  throw new DaemonError('no_such_session', `no session '${data.parent}'`);
635
751
  }
636
752
 
637
- // A sub-session spawning a sub-session of its own lands beside it,
638
- // so a set stays one level deep.
639
- parent = owner.parent ?? owner.id;
753
+ parent = resolved;
640
754
  }
641
755
 
642
756
  return {
@@ -662,6 +776,21 @@ export class DaemonConnection {
662
776
 
663
777
  const spawned = await ctx.spawnSession(plan, keyed, null);
664
778
 
779
+ const session = spawned['session'];
780
+
781
+ const sessionID =
782
+ isRecord(session) && typeof session['id'] === 'string' ? toSessionID(session['id']) : null;
783
+
784
+ const grant = sessionID === null ? null : ctx.findSessionGrant(sessionID);
785
+
786
+ // A live session whose tree left the view before the answer goes out
787
+ // gets the refusal a replay out of reach gets, holding no part of it.
788
+ // A replayed session no longer live was checked against its fleet row
789
+ // by the replay itself.
790
+ if (sessionID !== null && grant !== null && !ctx.isSessionVisible(sessionID)) {
791
+ throw buildTargetForbiddenError(grant.target);
792
+ }
793
+
665
794
  this.sendOk(req.id, spawned);
666
795
  }
667
796
 
@@ -822,9 +951,9 @@ export class DaemonConnection {
822
951
 
823
952
  const id = parsed.data.session;
824
953
 
825
- const record = await ctx.readSessionRecord(id);
954
+ const record = await ctx.readSessionRecord(id, null);
826
955
 
827
- if (record === 'missing') {
956
+ if (record === 'missing' || !ctx.isSessionVisible(id)) {
828
957
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
829
958
 
830
959
  return;
@@ -859,7 +988,7 @@ export class DaemonConnection {
859
988
 
860
989
  const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
861
990
 
862
- if (read === 'missing') {
991
+ if (read === 'missing' || !ctx.isSessionVisible(id)) {
863
992
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
864
993
  } else if (read === 'unsupported') {
865
994
  this.sendErr(
@@ -907,11 +1036,14 @@ export class DaemonConnection {
907
1036
  null,
908
1037
  );
909
1038
 
910
- const last = page.events.at(-1);
1039
+ // An event of a session that left the view while the read waited is
1040
+ // left out, as the read leaves out every event out of reach.
1041
+ const events = page.events.filter((event) => ctx.isSessionVisible(toSessionID(event.session)));
1042
+ const last = events.at(-1);
911
1043
 
912
1044
  // A cursor always comes back so a client can long-poll from an empty trail.
913
1045
  this.sendOk(req.id, {
914
- events: page.events,
1046
+ events,
915
1047
  cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
916
1048
  more: page.more,
917
1049
  });
@@ -931,16 +1063,18 @@ export class DaemonConnection {
931
1063
 
932
1064
  const decoded = decodeCursor(parsed.data.report);
933
1065
 
934
- const view =
1066
+ const read =
935
1067
  decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
936
1068
 
937
- if (view === null) {
1069
+ // Reach is checked against the session that sent the report, never the
1070
+ // session its view is named by.
1071
+ if (read === null || !ctx.isSessionVisible(read.owner)) {
938
1072
  this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
939
1073
 
940
1074
  return;
941
1075
  }
942
1076
 
943
- this.sendOk(req.id, { ...view });
1077
+ this.sendOk(req.id, { ...read.view });
944
1078
  }
945
1079
 
946
1080
  private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
@@ -964,9 +1098,10 @@ export class DaemonConnection {
964
1098
  parsed.data.from,
965
1099
  parsed.data.text,
966
1100
  keyed,
1101
+ null,
967
1102
  );
968
1103
 
969
- if (result === 'missing') {
1104
+ if (result === 'missing' || !ctx.isSessionVisible(sessionID)) {
970
1105
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
971
1106
 
972
1107
  return;
@@ -1007,7 +1142,7 @@ export class DaemonConnection {
1007
1142
  }
1008
1143
 
1009
1144
  const sessionID = parsed.data.session;
1010
- const result = ctx.attachTap(this, sessionID);
1145
+ const result = ctx.attachTap(this, sessionID, null);
1011
1146
 
1012
1147
  if (result === 'missing') {
1013
1148
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -1037,7 +1172,7 @@ export class DaemonConnection {
1037
1172
 
1038
1173
  const view = await ctx.readMessage(messageID, parsed.data.waitMs);
1039
1174
 
1040
- if (view === null) {
1175
+ if (view === null || !ctx.isSessionVisible(view.session)) {
1041
1176
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
1042
1177
 
1043
1178
  return;
@@ -1045,6 +1180,12 @@ export class DaemonConnection {
1045
1180
 
1046
1181
  const record = view.record;
1047
1182
 
1183
+ // A message the turn also answered is listed only while the session it
1184
+ // was sent to is in view, checked in the step that sends.
1185
+ const answeredWith = view.answeredWith
1186
+ .filter((sibling) => ctx.isSessionVisible(sibling.atcID))
1187
+ .map((sibling) => sibling.id);
1188
+
1048
1189
  this.sendOk(req.id, {
1049
1190
  message: record.id,
1050
1191
  session: view.session,
@@ -1056,7 +1197,7 @@ export class DaemonConnection {
1056
1197
  ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
1057
1198
  ...(record.answer === undefined ? {} : { answer: record.answer }),
1058
1199
  turn: record.turn ?? null,
1059
- answeredWith: view.answeredWith,
1200
+ answeredWith,
1060
1201
  });
1061
1202
  }
1062
1203
 
@@ -1072,7 +1213,7 @@ export class DaemonConnection {
1072
1213
  const sessionID = parsed.data.session;
1073
1214
  const messageID = parsed.data.message;
1074
1215
 
1075
- const result = await ctx.ackMessage(this, sessionID, messageID);
1216
+ const result = await ctx.ackMessage(this, sessionID, messageID, null);
1076
1217
 
1077
1218
  if (result === 'not_tapping') {
1078
1219
  this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
@@ -1080,7 +1221,7 @@ export class DaemonConnection {
1080
1221
  return;
1081
1222
  }
1082
1223
 
1083
- if (result === 'unknown') {
1224
+ if (result === 'unknown' || !ctx.isSessionVisible(sessionID)) {
1084
1225
  this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
1085
1226
 
1086
1227
  return;
@@ -1128,8 +1269,18 @@ export class DaemonConnection {
1128
1269
  const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
1129
1270
 
1130
1271
  if (principal !== null) {
1272
+ const access = this.ctx.buildTargetAccess(principal);
1273
+
1131
1274
  this.principal = principal;
1132
- this.access = this.ctx.buildTargetAccess(principal);
1275
+ this.access = access;
1276
+
1277
+ for (const session of this.ctx.collectSessions()) {
1278
+ const view = this.ctx.canSeeSession(session.id, access)
1279
+ ? this.shownSessions
1280
+ : this.withheldSessions;
1281
+
1282
+ view.add(session.id);
1283
+ }
1133
1284
  }
1134
1285
 
1135
1286
  if (req.v !== PROTOCOL_V) {
@@ -8,6 +8,7 @@ import type { MessageID } from '../shared/message-id';
8
8
  import type { SessionID } from '../shared/session-id';
9
9
  import type { FleetEntry } from '../store/fleet-entry';
10
10
  import type { MessageRecord } from '../store/message-record';
11
+ import type { TurnSibling } from '../store/state-store';
11
12
  import type { Dims } from './attach-registry';
12
13
  import type { AgentEntry } from './build-agent-list';
13
14
  import type { FleetEvent } from './build-fleet-events';
@@ -93,11 +94,12 @@ interface EventsPage {
93
94
  }
94
95
 
95
96
  // One message as `message.get` reports it: the session it belongs to now,
96
- // every field of the record, and the other messages its turn answered.
97
+ // every field of the record, and the other messages its turn answered with
98
+ // the atc id each was sent to.
97
99
  interface MessageView {
98
100
  readonly session: SessionID;
99
101
  readonly record: MessageRecord;
100
- readonly answeredWith: readonly MessageID[];
102
+ readonly answeredWith: readonly TurnSibling[];
101
103
  }
102
104
 
103
105
  // Why a session refuses a message before the daemon accepts it.
@@ -112,7 +114,10 @@ export interface DaemonContext {
112
114
  readonly build: string;
113
115
  readonly daemonID: DaemonID;
114
116
  readonly collectSessions: () => SessionDescriptor[];
115
- readonly collectSpawnDirs: () => Promise<string[]>;
117
+
118
+ // The directories spawns ran in, most recent first, leaving out each one
119
+ // spawned only on targets outside the access when there is one.
120
+ readonly collectSpawnDirs: (access: TargetAccess | null) => Promise<string[]>;
116
121
  readonly collectAgents: () => AgentList;
117
122
  readonly collectFleet: () => Promise<FleetEntry[]>;
118
123
  readonly loadLastUsedAgent: () => Promise<AgentID>;
@@ -129,13 +134,26 @@ export interface DaemonContext {
129
134
  // target.
130
135
  readonly findTargetIdentity: (target: string) => string | null;
131
136
 
132
- // The ids of a session's sub-sessions.
133
- readonly collectChildIDs: (id: SessionID) => SessionID[];
137
+ // Whether the access reaches every session in the given session's tree:
138
+ // its top-level session and each sub-session of that one. False for an
139
+ // unknown session.
140
+ readonly canSeeSession: (id: SessionID, access: TargetAccess) => boolean;
141
+
142
+ // Whether the request this context serves may still see the session: any
143
+ // session for the daemon's owner, and for a principal only a session whose
144
+ // whole tree it reaches. A read answers only after asking this, in the
145
+ // same step as it sends, so no await lies between the check and the send.
146
+ readonly isSessionVisible: (id: SessionID) => boolean;
134
147
 
135
148
  // The session a permission request belongs to, answered or not, or null
136
149
  // for an unknown request.
137
150
  readonly findPermissionSession: (request: string) => SessionID | null;
138
151
 
152
+ // The session a spawn under the given session lands under: that session,
153
+ // or its own parent for a sub-session, so a set stays one level deep.
154
+ // Null spawns the session top-level; 'missing' for an unknown session.
155
+ readonly resolveSpawnParent: (id: SessionID) => SessionID | null | 'missing';
156
+
139
157
  // The target a spawn runs on: the one it names, else the default. Throws
140
158
  // the refusal for a target the spawn cannot run on, and for a spawn
141
159
  // without a target when the config gives no default.
@@ -191,16 +209,26 @@ export interface DaemonContext {
191
209
  id: SessionID,
192
210
  prompt: string,
193
211
  ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
212
+
213
+ // Starts a harness for a dead or headless session. Under an access, the
214
+ // adopt answers 'missing' once the session's tree leaves it, however late.
194
215
  readonly adoptSession: (
195
216
  id: SessionID,
196
217
  cols: number,
197
218
  rows: number,
219
+ access: TargetAccess | null,
198
220
  ) => Promise<'ok' | 'missing' | 'no_transcript'>;
199
221
  readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
200
222
  readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
201
223
  readonly queueBytes?: number;
202
224
  readonly getEffectiveDims: (sessionID: SessionID) => Dims;
203
- readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
225
+
226
+ // The session's record. Its activity time counts only the session's own
227
+ // trail rows when there is an access.
228
+ readonly readSessionRecord: (
229
+ id: SessionID,
230
+ access: TargetAccess | null,
231
+ ) => Promise<SessionRecord | 'missing'>;
204
232
  readonly loadSessionTranscript: (
205
233
  id: SessionID,
206
234
  from: TranscriptPosition | null,
@@ -217,24 +245,47 @@ export interface DaemonContext {
217
245
  access: TargetAccess | null,
218
246
  ) => Promise<EventsPage>;
219
247
 
220
- // One report by the trail id of its event, or null for a trail id that
221
- // holds no report, or whose report's session is outside the access when
222
- // there is one.
223
- readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
248
+ // One report by the trail id of its event, with the atc id of the session
249
+ // that sent it, or null for a trail id that holds no report, or whose
250
+ // report's session is outside the access when there is one. The view may
251
+ // name the report by another session; who may read it is checked against
252
+ // the sender.
253
+ readonly readReport: (
254
+ id: number,
255
+ access: TargetAccess | null,
256
+ ) => Promise<{ readonly owner: SessionID; readonly view: ReportView } | null>;
224
257
 
225
258
  // Answers with the `session.message` ok payload, which a keyed retry
226
- // replays with the message's current status, or with the refusal.
259
+ // replays with the message's current status, or with the refusal. Under
260
+ // an access, a session whose tree leaves it before the write refuses as
261
+ // 'missing'.
227
262
  readonly writeSessionMessage: (
228
263
  sessionID: SessionID,
229
264
  from: string,
230
265
  text: string,
231
266
  keyed: KeyedRequest | null,
267
+ access: TargetAccess | null,
232
268
  ) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
233
269
  readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
234
- readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
270
+
271
+ // Makes the client the session's inbox tap. Under an access, the tap
272
+ // takes only the messages sent to the session's atc id.
273
+ readonly attachTap: (
274
+ client: TapClient,
275
+ sessionID: SessionID,
276
+ access: TargetAccess | null,
277
+ ) => 'ok' | 'missing' | 'unsupported';
278
+
279
+ // Lets go of the session's inbox tap when the client holds it, so its
280
+ // messages wait for another tap.
281
+ readonly detachTap: (client: TapClient, sessionID: SessionID) => void;
282
+
283
+ // Marks a tapped message delivered. Under an access, only a message sent
284
+ // to the session's atc id counts as the session's.
235
285
  readonly ackMessage: (
236
286
  client: TapClient,
237
287
  sessionID: SessionID,
238
288
  messageID: MessageID,
289
+ access: TargetAccess | null,
239
290
  ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
240
291
  }