@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.
- package/package.json +1 -1
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +241 -89
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
|
@@ -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
|
|
68
|
-
//
|
|
69
|
-
|
|
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
|
|
100
|
+
if (!this.helloed) {
|
|
87
101
|
return;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
|
-
|
|
91
|
-
this.
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
296
|
+
if (request !== null && this.shownRequests.has(request)) {
|
|
297
|
+
sent.push(event);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { events: sent, withdrawn };
|
|
234
301
|
}
|
|
235
302
|
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
330
|
+
// The session is gone from the daemon.
|
|
331
|
+
this.withheldSessions.delete(sessionID);
|
|
243
332
|
|
|
244
|
-
if (
|
|
245
|
-
|
|
333
|
+
if (this.shownSessions.has(sessionID)) {
|
|
334
|
+
sent.push(event);
|
|
246
335
|
}
|
|
247
336
|
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
747
|
+
const resolved = ctx.resolveSpawnParent(data.parent);
|
|
632
748
|
|
|
633
|
-
if (
|
|
749
|
+
if (resolved === 'missing') {
|
|
634
750
|
throw new DaemonError('no_such_session', `no session '${data.parent}'`);
|
|
635
751
|
}
|
|
636
752
|
|
|
637
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1066
|
+
const read =
|
|
935
1067
|
decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
|
|
936
1068
|
|
|
937
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
133
|
-
|
|
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
|
-
|
|
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,
|
|
221
|
-
//
|
|
222
|
-
// there is one.
|
|
223
|
-
|
|
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
|
-
|
|
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
|
}
|