@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
package/package.json
CHANGED
|
@@ -18,18 +18,24 @@ export interface FleetEvent {
|
|
|
18
18
|
readonly label?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Builds the events from the sessions that may name them. Rows written
|
|
23
|
+
* before atc session ids stayed stable across restores carry an earlier atc
|
|
24
|
+
* id, so a session of `aliases` that holds a row's agent session id names
|
|
25
|
+
* it when no session holds the atc id; every session that may name a row
|
|
26
|
+
* may alias it unless the caller gives fewer.
|
|
27
|
+
*/
|
|
21
28
|
export function buildFleetEvents(
|
|
22
29
|
rows: readonly StoredEvent[],
|
|
23
30
|
sessions: readonly SessionDescriptor[],
|
|
31
|
+
aliases: readonly SessionDescriptor[] = sessions,
|
|
24
32
|
): FleetEvent[] {
|
|
25
33
|
return rows.map((row) => {
|
|
26
|
-
// Rows written before atc session ids stayed stable across restores
|
|
27
|
-
// carry an earlier atc id, so the agent session id links them.
|
|
28
34
|
const live =
|
|
35
|
+
sessions.find((s) => s.id === row.atcID) ??
|
|
29
36
|
(row.agentSessionID === null
|
|
30
37
|
? undefined
|
|
31
|
-
:
|
|
32
|
-
sessions.find((s) => s.id === row.atcID);
|
|
38
|
+
: aliases.find((s) => s.agentSessionID === row.agentSessionID));
|
|
33
39
|
|
|
34
40
|
return {
|
|
35
41
|
cursor: encodeCursor({ kind: 'events', id: row.id }),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FleetEntry } from '../store/fleet-entry';
|
|
2
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
3
|
+
import type { TargetGrant } from './target-access';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The target a fleet row's session is bound to. A row that stores no
|
|
7
|
+
* target ran on the implicit `local` target, under its identity.
|
|
8
|
+
*/
|
|
9
|
+
export function buildGrantFromFleetEntry(entry: FleetEntry): TargetGrant {
|
|
10
|
+
return {
|
|
11
|
+
target: entry.target ?? 'local',
|
|
12
|
+
targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -17,17 +17,23 @@ export interface ReportView {
|
|
|
17
17
|
readonly complete: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Builds the view from the sessions that may name it. A row written before
|
|
22
|
+
* atc session ids stayed stable across restores carries an earlier atc id,
|
|
23
|
+
* so a session of `aliases` that holds its agent session id names it when
|
|
24
|
+
* no session holds the atc id. The alias only names the report: whoever
|
|
25
|
+
* checks who may read it checks the row's own atc id.
|
|
26
|
+
*/
|
|
20
27
|
export function buildReportView(
|
|
21
28
|
stored: StoredReport,
|
|
22
29
|
sessions: readonly SessionDescriptor[],
|
|
30
|
+
aliases: readonly SessionDescriptor[],
|
|
23
31
|
): ReportView {
|
|
24
|
-
// A row written before atc session ids stayed stable across restores
|
|
25
|
-
// carries an earlier atc id, so the agent session id links it.
|
|
26
32
|
const live =
|
|
33
|
+
sessions.find((s) => s.id === stored.atcID) ??
|
|
27
34
|
(stored.agentSessionID === null
|
|
28
35
|
? undefined
|
|
29
|
-
:
|
|
30
|
-
sessions.find((s) => s.id === stored.atcID);
|
|
36
|
+
: aliases.find((s) => s.agentSessionID === stored.agentSessionID));
|
|
31
37
|
|
|
32
38
|
return {
|
|
33
39
|
report: encodeCursor({ kind: 'events', id: stored.id }),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
3
|
import { toSessionID } from '../shared/to-session-id';
|
|
4
|
+
import { buildGrantFromFleetEntry } from './build-grant-from-fleet-entry';
|
|
4
5
|
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
5
|
-
import { buildTargetIdentity } from './build-target-identity';
|
|
6
6
|
import type { DaemonContext } from './daemon-context';
|
|
7
7
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
8
8
|
import type { TargetAccess, TargetGrant } from './target-access';
|
|
@@ -10,14 +10,16 @@ import type { TargetAccess, TargetGrant } from './target-access';
|
|
|
10
10
|
/**
|
|
11
11
|
* The daemon as a principal with the given access sees it. Its keyed
|
|
12
12
|
* spawns and messages hold their keys in the given namespace, apart from
|
|
13
|
-
* every other namespace's. A session
|
|
14
|
-
*
|
|
15
|
-
* session
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* every other namespace's. A session exists here only when the access
|
|
14
|
+
* reaches every session in its tree: its top-level session and each
|
|
15
|
+
* sub-session of that one. Every lookup of any other session answers as a
|
|
16
|
+
* lookup of an unknown session does, so the request's own handling refuses
|
|
17
|
+
* it with the words and data it gives a session that never existed. Lists
|
|
18
|
+
* leave such sessions out, the directory list leaves out directories
|
|
19
|
+
* spawned only on targets outside the access, and a spawn may use only a
|
|
20
|
+
* target the access holds, a spawn's replayed answer included. A kill, a
|
|
21
|
+
* forget, or a change checks the tree in the same synchronous step that
|
|
22
|
+
* starts it, so a sub-session added after the check is never part of it.
|
|
21
23
|
*/
|
|
22
24
|
export function buildScopedContext(
|
|
23
25
|
ctx: DaemonContext,
|
|
@@ -28,11 +30,11 @@ export function buildScopedContext(
|
|
|
28
30
|
const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
|
|
29
31
|
keyed === null ? null : { ...keyed, principal: keyNamespace };
|
|
30
32
|
|
|
31
|
-
const canSee = (id: SessionID): boolean =>
|
|
32
|
-
const grant = ctx.findSessionGrant(id);
|
|
33
|
+
const canSee = (id: SessionID): boolean => ctx.canSeeSession(id, access);
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// A request's own access narrowed to this one.
|
|
36
|
+
const mergeAccess = (outer: TargetAccess | null): TargetAccess =>
|
|
37
|
+
outer === null ? access : outer.merge(access);
|
|
36
38
|
|
|
37
39
|
const canUseTarget = (target: string): boolean => {
|
|
38
40
|
const targetIdentity = ctx.findTargetIdentity(target);
|
|
@@ -57,10 +59,7 @@ export function buildScopedContext(
|
|
|
57
59
|
return null;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
return
|
|
61
|
-
target: entry.target ?? 'local',
|
|
62
|
-
targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
|
|
63
|
-
};
|
|
62
|
+
return buildGrantFromFleetEntry(entry);
|
|
64
63
|
};
|
|
65
64
|
|
|
66
65
|
// Throws the refusal a fresh spawn to the session's target gets when a
|
|
@@ -73,24 +72,9 @@ export function buildScopedContext(
|
|
|
73
72
|
}
|
|
74
73
|
};
|
|
75
74
|
|
|
76
|
-
// Throws the refusal of a change to a session whose sub-sessions the
|
|
77
|
-
// change would reach too when any of them is out of reach. The refusal
|
|
78
|
-
// holds only the session the caller sent, never a sub-session's id or
|
|
79
|
-
// how many there are.
|
|
80
|
-
const requireTreeInReach = (id: SessionID, action: string): void => {
|
|
81
|
-
if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
throw new DaemonError(
|
|
86
|
-
'target_forbidden',
|
|
87
|
-
`this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
|
|
88
|
-
{ session: id },
|
|
89
|
-
);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
75
|
return {
|
|
93
76
|
...ctx,
|
|
77
|
+
isSessionVisible: canSee,
|
|
94
78
|
collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
|
|
95
79
|
collectAgents: () => {
|
|
96
80
|
const list = ctx.collectAgents();
|
|
@@ -115,11 +99,12 @@ export function buildScopedContext(
|
|
|
115
99
|
}),
|
|
116
100
|
};
|
|
117
101
|
},
|
|
118
|
-
|
|
119
|
-
const
|
|
102
|
+
collectSpawnDirs: (outer) => {
|
|
103
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
120
104
|
|
|
121
|
-
return
|
|
105
|
+
return ctx.collectSpawnDirs(merged);
|
|
122
106
|
},
|
|
107
|
+
resolveSpawnParent: (id) => (canSee(id) ? ctx.resolveSpawnParent(id) : 'missing'),
|
|
123
108
|
resolveSpawnTarget: (requested) => {
|
|
124
109
|
if (requested !== undefined) {
|
|
125
110
|
requireTarget(canUseTarget, requested);
|
|
@@ -132,12 +117,10 @@ export function buildScopedContext(
|
|
|
132
117
|
return target;
|
|
133
118
|
},
|
|
134
119
|
spawnSession: async (plan, keyed) => {
|
|
135
|
-
let answer: Readonly<Record<string, unknown>>;
|
|
136
|
-
|
|
137
120
|
// A held key's replay is checked against the target its session was
|
|
138
121
|
// bound to, inside the spawn, before its answer leaves the daemon.
|
|
139
122
|
try {
|
|
140
|
-
|
|
123
|
+
return await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
|
|
141
124
|
} catch (error) {
|
|
142
125
|
// A held key's refusal may carry the session its spawn made.
|
|
143
126
|
if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
|
|
@@ -146,29 +129,17 @@ export function buildScopedContext(
|
|
|
146
129
|
|
|
147
130
|
throw error;
|
|
148
131
|
}
|
|
149
|
-
|
|
150
|
-
return answer;
|
|
151
132
|
},
|
|
152
|
-
killSession: (id) => {
|
|
153
|
-
if (!canSee(id)) {
|
|
154
|
-
return Promise.resolve(false);
|
|
155
|
-
}
|
|
156
133
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
},
|
|
161
|
-
updateSession: (id, name, pinned) => {
|
|
162
|
-
if (!canSee(id)) {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
134
|
+
// The daemon's kill takes the session's sub-sessions before its first
|
|
135
|
+
// await, so the set it kills is the set this check saw.
|
|
136
|
+
killSession: (id) => (canSee(id) ? ctx.killSession(id) : Promise.resolve(false)),
|
|
165
137
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
},
|
|
138
|
+
// A session out of reach answers before any confirm token is handed
|
|
139
|
+
// out or taken, so its host is never touched.
|
|
140
|
+
forgetSession: (id, confirmToken) =>
|
|
141
|
+
canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
|
|
142
|
+
updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
|
|
172
143
|
ackSession: (id) => canSee(id) && ctx.ackSession(id),
|
|
173
144
|
buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
|
|
174
145
|
readSessionScreen: (id) =>
|
|
@@ -190,12 +161,16 @@ export function buildScopedContext(
|
|
|
190
161
|
writeSessionLine: (sessionID, text) =>
|
|
191
162
|
canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
|
|
192
163
|
ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
|
|
193
|
-
adoptSession: (id, cols, rows) =>
|
|
194
|
-
canSee(id)
|
|
164
|
+
adoptSession: (id, cols, rows, outer) =>
|
|
165
|
+
canSee(id)
|
|
166
|
+
? ctx.adoptSession(id, cols, rows, mergeAccess(outer))
|
|
167
|
+
: Promise.resolve('missing' as const),
|
|
195
168
|
resizeSession: (client, sessionID, dims) =>
|
|
196
169
|
canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
|
|
197
|
-
readSessionRecord: (id) =>
|
|
198
|
-
canSee(id)
|
|
170
|
+
readSessionRecord: (id, outer) =>
|
|
171
|
+
canSee(id)
|
|
172
|
+
? ctx.readSessionRecord(id, mergeAccess(outer))
|
|
173
|
+
: Promise.resolve('missing' as const),
|
|
199
174
|
loadSessionTranscript: (id, from, limit) =>
|
|
200
175
|
canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
|
|
201
176
|
readEvents: (afterID, limit, waitMs, sessionID, outer) => {
|
|
@@ -203,31 +178,43 @@ export function buildScopedContext(
|
|
|
203
178
|
|
|
204
179
|
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
205
180
|
},
|
|
206
|
-
readReport: (id, outer) =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return ctx.readReport(id, merged);
|
|
210
|
-
},
|
|
211
|
-
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
181
|
+
readReport: (id, outer) => ctx.readReport(id, mergeAccess(outer)),
|
|
182
|
+
writeSessionMessage: (sessionID, from, text, keyed, outer) =>
|
|
212
183
|
canSee(sessionID)
|
|
213
|
-
? ctx.writeSessionMessage(
|
|
184
|
+
? ctx.writeSessionMessage(
|
|
185
|
+
sessionID,
|
|
186
|
+
from,
|
|
187
|
+
text,
|
|
188
|
+
buildPrincipalKey(keyed),
|
|
189
|
+
mergeAccess(outer),
|
|
190
|
+
)
|
|
214
191
|
: Promise.resolve('missing' as const),
|
|
215
192
|
readMessage: async (messageID, waitMs) => {
|
|
216
193
|
// The owner is checked before any wait, so a message outside the
|
|
217
194
|
// access answers at once, as an unknown message does.
|
|
218
195
|
const view = await ctx.readMessage(messageID, 0);
|
|
219
196
|
|
|
220
|
-
|
|
197
|
+
// Only the session the message was sent to owns it here, never one
|
|
198
|
+
// that shares its agent session id.
|
|
199
|
+
if (view === null || view.session !== view.record.atcID || !canSee(view.session)) {
|
|
221
200
|
return null;
|
|
222
201
|
}
|
|
223
202
|
|
|
224
|
-
|
|
203
|
+
if (waitMs === 0) {
|
|
204
|
+
return view;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The message may move to another session that holds its agent
|
|
208
|
+
// session id while the read waits.
|
|
209
|
+
const waited = await ctx.readMessage(messageID, waitMs);
|
|
210
|
+
|
|
211
|
+
return waited !== null && waited.session === waited.record.atcID ? waited : null;
|
|
225
212
|
},
|
|
226
|
-
attachTap: (client, sessionID) =>
|
|
227
|
-
canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
|
|
228
|
-
ackMessage: (client, sessionID, messageID) =>
|
|
213
|
+
attachTap: (client, sessionID, outer) =>
|
|
214
|
+
canSee(sessionID) ? ctx.attachTap(client, sessionID, mergeAccess(outer)) : 'missing',
|
|
215
|
+
ackMessage: (client, sessionID, messageID, outer) =>
|
|
229
216
|
canSee(sessionID)
|
|
230
|
-
? ctx.ackMessage(client, sessionID, messageID)
|
|
217
|
+
? ctx.ackMessage(client, sessionID, messageID, mergeAccess(outer))
|
|
231
218
|
: Promise.resolve('unknown' as const),
|
|
232
219
|
};
|
|
233
220
|
}
|