@zgeoff/atc 2.15.0 → 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 +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +2 -1
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +64 -77
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +206 -288
- package/src/daemon/daemon-context.ts +291 -0
- package/src/daemon/daemon.ts +250 -94
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +175 -123
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/daemon/tap-registry.ts +43 -5
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/store/fleet-entry.ts +2 -2
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +49 -13
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { basename, isAbsolute } from 'node:path';
|
|
2
|
-
import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
|
|
3
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
4
3
|
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
5
4
|
import { decodeCursor } from '../protocol/decode-cursor';
|
|
@@ -16,248 +15,17 @@ import {
|
|
|
16
15
|
encodeMessage,
|
|
17
16
|
} from '../protocol/protocol';
|
|
18
17
|
import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
|
|
19
|
-
import type {
|
|
20
|
-
import type { TargetConfigError } from '../shared/collect-targets';
|
|
21
|
-
import type { DaemonID } from '../shared/daemon-id';
|
|
22
|
-
import type { MessageID } from '../shared/message-id';
|
|
18
|
+
import type { AgentID } from '../shared/agent-id';
|
|
23
19
|
import { isRecord } from '../shared/report';
|
|
24
20
|
import type { SessionID } from '../shared/session-id';
|
|
25
21
|
import { toSessionID } from '../shared/to-session-id';
|
|
26
|
-
import type { FleetEntry } from '../store/fleet-entry';
|
|
27
|
-
import type { MessageRecord } from '../store/message-record';
|
|
28
|
-
import type { Dims } from './attach-registry';
|
|
29
|
-
import type { AgentEntry } from './build-agent-list';
|
|
30
|
-
import type { FleetEvent } from './build-fleet-events';
|
|
31
22
|
import { buildPayloadHash } from './build-payload-hash';
|
|
32
|
-
import type { ReportView } from './build-report-view';
|
|
33
23
|
import { buildScopedContext } from './build-scoped-context';
|
|
34
|
-
import
|
|
35
|
-
import type {
|
|
36
|
-
import type {
|
|
24
|
+
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
25
|
+
import type { DaemonContext, SpawnParams } from './daemon-context';
|
|
26
|
+
import type { TranscriptPosition } from './load-transcript-page';
|
|
37
27
|
import { parseSpawnOverrides } from './parse-spawn-overrides';
|
|
38
|
-
import type {
|
|
39
|
-
import type { ScreenText } from './screen-model';
|
|
40
|
-
import type { SessionDescriptor } from './sessions';
|
|
41
|
-
import type { TargetAccess, TargetGrant } from './target-access';
|
|
42
|
-
|
|
43
|
-
export interface SpawnParams {
|
|
44
|
-
readonly cwd: string;
|
|
45
|
-
readonly name: string;
|
|
46
|
-
readonly prompt: string;
|
|
47
|
-
readonly cols: number;
|
|
48
|
-
readonly rows: number;
|
|
49
|
-
readonly resume: SpawnOptions['resume'];
|
|
50
|
-
readonly namedBy: 'user' | 'auto';
|
|
51
|
-
readonly agent: AgentID;
|
|
52
|
-
readonly parent: SessionID | null;
|
|
53
|
-
readonly overrides: SpawnOverrides;
|
|
54
|
-
readonly target: string;
|
|
55
|
-
|
|
56
|
-
// Where the session's working directory comes from; null runs the
|
|
57
|
-
// session in cwd as it stands.
|
|
58
|
-
readonly workspace: SpawnWorkspaceSource | null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface SessionRecord {
|
|
62
|
-
readonly session: SessionDescriptor;
|
|
63
|
-
readonly prompt: string | null;
|
|
64
|
-
readonly lastActivityAt: number;
|
|
65
|
-
readonly pending: { readonly message: string } | null;
|
|
66
|
-
readonly result: string | null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// A transcript page with the file it came from, so a cursor into a replaced
|
|
70
|
-
// transcript is distinguishable from one into a grown transcript.
|
|
71
|
-
/**
|
|
72
|
-
* What `session.forget` answers: the token a forget that destroys a host
|
|
73
|
-
* must carry, with the time it stops being taken; or the forgotten session,
|
|
74
|
-
* with whether its host was destroyed.
|
|
75
|
-
*/
|
|
76
|
-
type ForgetResult =
|
|
77
|
-
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
78
|
-
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
79
|
-
|
|
80
|
-
interface SessionTranscriptRead {
|
|
81
|
-
readonly path: string;
|
|
82
|
-
readonly page: TranscriptPage;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface DaemonContext {
|
|
86
|
-
readonly build: string;
|
|
87
|
-
readonly daemonID: DaemonID;
|
|
88
|
-
readonly collectSessions: () => SessionDescriptor[];
|
|
89
|
-
readonly collectSpawnDirs: () => Promise<string[]>;
|
|
90
|
-
readonly collectAgents: () => AgentList;
|
|
91
|
-
readonly collectFleet: () => Promise<FleetEntry[]>;
|
|
92
|
-
readonly loadLastUsedAgent: () => Promise<AgentID>;
|
|
93
|
-
readonly findAdapter: (id: AgentID) => AgentAdapter | null;
|
|
94
|
-
|
|
95
|
-
// The targets a principal may use.
|
|
96
|
-
readonly buildTargetAccess: (principal: string) => TargetAccess;
|
|
97
|
-
|
|
98
|
-
// The target and identity a session is bound to, or null when no
|
|
99
|
-
// session holds the id.
|
|
100
|
-
readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
|
|
101
|
-
|
|
102
|
-
// The identity a target holds now, or null when the config holds no such
|
|
103
|
-
// target.
|
|
104
|
-
readonly findTargetIdentity: (target: string) => string | null;
|
|
105
|
-
|
|
106
|
-
// The ids of a session's sub-sessions.
|
|
107
|
-
readonly collectChildIDs: (id: SessionID) => SessionID[];
|
|
108
|
-
|
|
109
|
-
// The session a permission request belongs to, answered or not, or null
|
|
110
|
-
// for an unknown request.
|
|
111
|
-
readonly findPermissionSession: (request: string) => SessionID | null;
|
|
112
|
-
|
|
113
|
-
// The target a spawn runs on: the one it names, else the default. Throws
|
|
114
|
-
// the refusal for a target the spawn cannot run on, and for a spawn
|
|
115
|
-
// without a target when the config gives no default.
|
|
116
|
-
readonly resolveSpawnTarget: (requested: string | undefined) => string;
|
|
117
|
-
|
|
118
|
-
// Throws the refusal for a target that cannot materialize a workspace:
|
|
119
|
-
// one whose provider cannot both transfer an archive and run a command.
|
|
120
|
-
readonly requireWorkspaceTarget: (target: string) => void;
|
|
121
|
-
|
|
122
|
-
// Runs the plan, which throws the refusal for a spawn it refuses, then
|
|
123
|
-
// spawns. Answers with the `session.spawn` ok payload, which a keyed
|
|
124
|
-
// retry replays as the first spawn answered it while the access, when
|
|
125
|
-
// there is one, still reaches the target the spawn's session was bound to.
|
|
126
|
-
readonly spawnSession: (
|
|
127
|
-
plan: () => SpawnParams,
|
|
128
|
-
keyed: KeyedRequest | null,
|
|
129
|
-
access: TargetAccess | null,
|
|
130
|
-
) => Promise<Readonly<Record<string, unknown>>>;
|
|
131
|
-
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
132
|
-
|
|
133
|
-
// Forgets a session, or answers with the token a forget that destroys a
|
|
134
|
-
// host must carry. Throws the refusal for a token it does not take.
|
|
135
|
-
readonly forgetSession: (
|
|
136
|
-
id: SessionID,
|
|
137
|
-
confirmToken: string | undefined,
|
|
138
|
-
) => Promise<ForgetResult | 'missing'>;
|
|
139
|
-
readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
|
|
140
|
-
readonly quitDaemon: () => void;
|
|
141
|
-
readonly ackSession: (id: SessionID) => boolean;
|
|
142
|
-
readonly buildResumeCommand: (id: SessionID) => string | null;
|
|
143
|
-
readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
|
|
144
|
-
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
145
|
-
readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
|
|
146
|
-
readonly attachSession: (
|
|
147
|
-
client: OutputClient,
|
|
148
|
-
sessionID: SessionID,
|
|
149
|
-
dims: Dims,
|
|
150
|
-
) => 'ok' | 'missing' | 'dead';
|
|
151
|
-
readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
|
|
152
|
-
readonly detachClient: (client: OutputClient) => void;
|
|
153
|
-
readonly writeSessionInput: (
|
|
154
|
-
sessionID: SessionID,
|
|
155
|
-
data: string,
|
|
156
|
-
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
157
|
-
|
|
158
|
-
// Types a line into the session and submits it the way the session's
|
|
159
|
-
// agent accepts a line, as opposed to the raw bytes the input write takes.
|
|
160
|
-
readonly writeSessionLine: (
|
|
161
|
-
sessionID: SessionID,
|
|
162
|
-
text: string,
|
|
163
|
-
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
164
|
-
readonly ejectSession: (
|
|
165
|
-
id: SessionID,
|
|
166
|
-
prompt: string,
|
|
167
|
-
) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
|
|
168
|
-
readonly adoptSession: (
|
|
169
|
-
id: SessionID,
|
|
170
|
-
cols: number,
|
|
171
|
-
rows: number,
|
|
172
|
-
) => Promise<'ok' | 'missing' | 'no_transcript'>;
|
|
173
|
-
readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
|
|
174
|
-
readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
|
|
175
|
-
readonly queueBytes?: number;
|
|
176
|
-
readonly getEffectiveDims: (sessionID: SessionID) => Dims;
|
|
177
|
-
readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
|
|
178
|
-
readonly loadSessionTranscript: (
|
|
179
|
-
id: SessionID,
|
|
180
|
-
from: TranscriptPosition | null,
|
|
181
|
-
limit: number,
|
|
182
|
-
) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
|
|
183
|
-
|
|
184
|
-
// Reads the trail, leaving out each event of a session outside the access
|
|
185
|
-
// when there is one.
|
|
186
|
-
readonly readEvents: (
|
|
187
|
-
afterID: number | null,
|
|
188
|
-
limit: number,
|
|
189
|
-
waitMs: number,
|
|
190
|
-
sessionID: SessionID | null,
|
|
191
|
-
access: TargetAccess | null,
|
|
192
|
-
) => Promise<EventsPage>;
|
|
193
|
-
|
|
194
|
-
// One report by the trail id of its event, or null for a trail id that
|
|
195
|
-
// holds no report, or whose report's session is outside the access when
|
|
196
|
-
// there is one.
|
|
197
|
-
readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
|
|
198
|
-
|
|
199
|
-
// Answers with the `session.message` ok payload, which a keyed retry
|
|
200
|
-
// replays with the message's current status, or with the refusal.
|
|
201
|
-
readonly writeSessionMessage: (
|
|
202
|
-
sessionID: SessionID,
|
|
203
|
-
from: string,
|
|
204
|
-
text: string,
|
|
205
|
-
keyed: KeyedRequest | null,
|
|
206
|
-
) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
|
|
207
|
-
readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
|
|
208
|
-
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
209
|
-
readonly ackMessage: (
|
|
210
|
-
client: TapClient,
|
|
211
|
-
sessionID: SessionID,
|
|
212
|
-
messageID: MessageID,
|
|
213
|
-
) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// The slice of a connection the attach bookkeeping needs: identity plus the
|
|
217
|
-
// ability to receive output events.
|
|
218
|
-
export interface OutputClient {
|
|
219
|
-
readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// The `agents.list` answer: the host the daemon runs on, each agent, each
|
|
223
|
-
// execution target, what a spawn without either runs with, a digest of
|
|
224
|
-
// the target config, and the target config problems the daemon started
|
|
225
|
-
// with.
|
|
226
|
-
interface AgentList {
|
|
227
|
-
readonly daemon: {
|
|
228
|
-
readonly hostname: string;
|
|
229
|
-
readonly platform: string;
|
|
230
|
-
readonly arch: string;
|
|
231
|
-
readonly build: string;
|
|
232
|
-
};
|
|
233
|
-
readonly agents: readonly AgentEntry[];
|
|
234
|
-
readonly targets: readonly TargetEntry[];
|
|
235
|
-
readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
|
|
236
|
-
readonly configRevision: string;
|
|
237
|
-
readonly targetErrors: readonly TargetConfigError[];
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// One `events.read` answer: the events, and whether more follow them.
|
|
241
|
-
interface EventsPage {
|
|
242
|
-
readonly events: readonly FleetEvent[];
|
|
243
|
-
readonly more: boolean;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// One message as `message.get` reports it: the session it belongs to now,
|
|
247
|
-
// every field of the record, and the other messages its turn answered.
|
|
248
|
-
interface MessageView {
|
|
249
|
-
readonly session: SessionID;
|
|
250
|
-
readonly record: MessageRecord;
|
|
251
|
-
readonly answeredWith: readonly MessageID[];
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Why a session refuses a message before the daemon accepts it.
|
|
255
|
-
export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
|
|
256
|
-
|
|
257
|
-
// The slice of a connection a tap subscription needs.
|
|
258
|
-
export interface TapClient {
|
|
259
|
-
readonly sendEvent: (event: EventMsg) => void;
|
|
260
|
-
}
|
|
28
|
+
import type { TargetAccess } from './target-access';
|
|
261
29
|
|
|
262
30
|
// The targets a request may use and the namespace of its idempotency keys.
|
|
263
31
|
interface RequestScope {
|
|
@@ -268,6 +36,13 @@ interface RequestScope {
|
|
|
268
36
|
// The requests that act on the whole daemon, which only its owner may make.
|
|
269
37
|
const OWNER_METHODS: ReadonlySet<string> = new Set(['daemon.quit', 'fleet.restore']);
|
|
270
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
|
+
|
|
271
46
|
interface PeerSocket extends SocketWriter {
|
|
272
47
|
readonly end: () => void;
|
|
273
48
|
}
|
|
@@ -297,9 +72,15 @@ export class DaemonConnection {
|
|
|
297
72
|
|
|
298
73
|
private access: TargetAccess | null = null;
|
|
299
74
|
|
|
300
|
-
// The sessions
|
|
301
|
-
//
|
|
302
|
-
|
|
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>();
|
|
303
84
|
|
|
304
85
|
constructor(peer: PeerSocket, ctx: DaemonContext) {
|
|
305
86
|
this.peer = peer;
|
|
@@ -316,12 +97,30 @@ export class DaemonConnection {
|
|
|
316
97
|
}
|
|
317
98
|
|
|
318
99
|
sendEvent(event: EventMsg): void {
|
|
319
|
-
if (!this.helloed
|
|
100
|
+
if (!this.helloed) {
|
|
320
101
|
return;
|
|
321
102
|
}
|
|
322
103
|
|
|
323
|
-
|
|
324
|
-
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);
|
|
325
124
|
}
|
|
326
125
|
}
|
|
327
126
|
|
|
@@ -330,7 +129,7 @@ export class DaemonConnection {
|
|
|
330
129
|
// intermediate chunk is never dropped without that resync, because a byte
|
|
331
130
|
// stream cut mid-escape corrupts the client's terminal state.
|
|
332
131
|
sendOutput(sessionID: SessionID, event: EventMsg, byteLength: number): void {
|
|
333
|
-
if (!this.helloed) {
|
|
132
|
+
if (!this.helloed || !this.canSeeOutput(sessionID)) {
|
|
334
133
|
return;
|
|
335
134
|
}
|
|
336
135
|
|
|
@@ -380,11 +179,21 @@ export class DaemonConnection {
|
|
|
380
179
|
|
|
381
180
|
for (const [sessionID, dropped] of this.desynced) {
|
|
382
181
|
this.desynced.delete(sessionID);
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
}
|
|
385
187
|
}
|
|
386
188
|
}
|
|
387
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
|
+
|
|
388
197
|
// false: the connection is beyond recovery and gets closed.
|
|
389
198
|
private applyLine(line: string): boolean {
|
|
390
199
|
const decoded = decodeMessage(line);
|
|
@@ -451,34 +260,99 @@ export class DaemonConnection {
|
|
|
451
260
|
return { access: this.access.merge(access), keyNamespace: `client:${this.principal}` };
|
|
452
261
|
}
|
|
453
262
|
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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 {
|
|
462
271
|
const sessionID = findEventSession(event);
|
|
463
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
|
+
}
|
|
464
294
|
|
|
465
295
|
if (sessionID === null) {
|
|
466
|
-
|
|
296
|
+
if (request !== null && this.shownRequests.has(request)) {
|
|
297
|
+
sent.push(event);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { events: sent, withdrawn };
|
|
301
|
+
}
|
|
302
|
+
|
|
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);
|
|
309
|
+
|
|
310
|
+
if (request !== null) {
|
|
311
|
+
this.shownRequests.add(request);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
sent.push(event);
|
|
315
|
+
|
|
316
|
+
return { events: sent, withdrawn };
|
|
467
317
|
}
|
|
468
318
|
|
|
469
|
-
|
|
319
|
+
if (this.isWithheld(sessionID, access)) {
|
|
320
|
+
this.withheldSessions.add(sessionID);
|
|
470
321
|
|
|
471
|
-
|
|
472
|
-
|
|
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 };
|
|
473
328
|
}
|
|
474
329
|
|
|
475
|
-
|
|
330
|
+
// The session is gone from the daemon.
|
|
331
|
+
this.withheldSessions.delete(sessionID);
|
|
476
332
|
|
|
477
|
-
if (
|
|
478
|
-
|
|
333
|
+
if (this.shownSessions.has(sessionID)) {
|
|
334
|
+
sent.push(event);
|
|
479
335
|
}
|
|
480
336
|
|
|
481
|
-
|
|
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 }];
|
|
482
356
|
}
|
|
483
357
|
|
|
484
358
|
// A store query that rejects must end one request, never the daemon: a
|
|
@@ -522,12 +396,16 @@ export class DaemonConnection {
|
|
|
522
396
|
return;
|
|
523
397
|
}
|
|
524
398
|
case 'dirs.list': {
|
|
525
|
-
this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs() });
|
|
399
|
+
this.sendOk(req.id, { dirs: await ctx.collectSpawnDirs(null) });
|
|
526
400
|
|
|
527
401
|
return;
|
|
528
402
|
}
|
|
529
403
|
case 'fleet.list': {
|
|
530
|
-
|
|
404
|
+
const fleet = await ctx.collectFleet();
|
|
405
|
+
|
|
406
|
+
this.sendOk(req.id, {
|
|
407
|
+
fleet: fleet.filter((entry) => ctx.isSessionVisible(entry.sessionID)),
|
|
408
|
+
});
|
|
531
409
|
|
|
532
410
|
return;
|
|
533
411
|
}
|
|
@@ -589,7 +467,7 @@ export class DaemonConnection {
|
|
|
589
467
|
|
|
590
468
|
const id = parsed.data.session;
|
|
591
469
|
|
|
592
|
-
const forgotten = await
|
|
470
|
+
const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken);
|
|
593
471
|
|
|
594
472
|
if (forgotten === 'missing') {
|
|
595
473
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
@@ -632,7 +510,7 @@ export class DaemonConnection {
|
|
|
632
510
|
|
|
633
511
|
const screen = await ctx.readSessionScreen(id);
|
|
634
512
|
|
|
635
|
-
if (screen === 'missing') {
|
|
513
|
+
if (screen === 'missing' || !ctx.isSessionVisible(id)) {
|
|
636
514
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
637
515
|
} else if (screen === 'no_screen') {
|
|
638
516
|
this.sendErr(req.id, 'session_dead', `session '${id}' has no captured screen`);
|
|
@@ -685,7 +563,12 @@ export class DaemonConnection {
|
|
|
685
563
|
|
|
686
564
|
const sessionID = parsed.data.session;
|
|
687
565
|
|
|
688
|
-
const adoptResult = await ctx.adoptSession(
|
|
566
|
+
const adoptResult = await ctx.adoptSession(
|
|
567
|
+
sessionID,
|
|
568
|
+
parsed.data.cols,
|
|
569
|
+
parsed.data.rows,
|
|
570
|
+
null,
|
|
571
|
+
);
|
|
689
572
|
|
|
690
573
|
if (adoptResult === 'ok') {
|
|
691
574
|
this.sendOk(req.id, {});
|
|
@@ -849,7 +732,7 @@ export class DaemonConnection {
|
|
|
849
732
|
// Checked before the spawn starts, so a target that cannot take a
|
|
850
733
|
// workspace refuses it before any git command runs.
|
|
851
734
|
if (data.workspace !== undefined) {
|
|
852
|
-
|
|
735
|
+
ctx.requireWorkspaceTarget(target);
|
|
853
736
|
}
|
|
854
737
|
|
|
855
738
|
// A workspace is materialized at cwd on the target, which only an
|
|
@@ -861,15 +744,13 @@ export class DaemonConnection {
|
|
|
861
744
|
let parent: SessionID | null = null;
|
|
862
745
|
|
|
863
746
|
if (data.parent !== undefined) {
|
|
864
|
-
const
|
|
747
|
+
const resolved = ctx.resolveSpawnParent(data.parent);
|
|
865
748
|
|
|
866
|
-
if (
|
|
749
|
+
if (resolved === 'missing') {
|
|
867
750
|
throw new DaemonError('no_such_session', `no session '${data.parent}'`);
|
|
868
751
|
}
|
|
869
752
|
|
|
870
|
-
|
|
871
|
-
// so a set stays one level deep.
|
|
872
|
-
parent = owner.parent ?? owner.id;
|
|
753
|
+
parent = resolved;
|
|
873
754
|
}
|
|
874
755
|
|
|
875
756
|
return {
|
|
@@ -895,6 +776,21 @@ export class DaemonConnection {
|
|
|
895
776
|
|
|
896
777
|
const spawned = await ctx.spawnSession(plan, keyed, null);
|
|
897
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
|
+
|
|
898
794
|
this.sendOk(req.id, spawned);
|
|
899
795
|
}
|
|
900
796
|
|
|
@@ -1055,9 +951,9 @@ export class DaemonConnection {
|
|
|
1055
951
|
|
|
1056
952
|
const id = parsed.data.session;
|
|
1057
953
|
|
|
1058
|
-
const record = await ctx.readSessionRecord(id);
|
|
954
|
+
const record = await ctx.readSessionRecord(id, null);
|
|
1059
955
|
|
|
1060
|
-
if (record === 'missing') {
|
|
956
|
+
if (record === 'missing' || !ctx.isSessionVisible(id)) {
|
|
1061
957
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
1062
958
|
|
|
1063
959
|
return;
|
|
@@ -1092,7 +988,7 @@ export class DaemonConnection {
|
|
|
1092
988
|
|
|
1093
989
|
const read = await ctx.loadSessionTranscript(id, from, parsed.data.limit);
|
|
1094
990
|
|
|
1095
|
-
if (read === 'missing') {
|
|
991
|
+
if (read === 'missing' || !ctx.isSessionVisible(id)) {
|
|
1096
992
|
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
1097
993
|
} else if (read === 'unsupported') {
|
|
1098
994
|
this.sendErr(
|
|
@@ -1140,11 +1036,14 @@ export class DaemonConnection {
|
|
|
1140
1036
|
null,
|
|
1141
1037
|
);
|
|
1142
1038
|
|
|
1143
|
-
|
|
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);
|
|
1144
1043
|
|
|
1145
1044
|
// A cursor always comes back so a client can long-poll from an empty trail.
|
|
1146
1045
|
this.sendOk(req.id, {
|
|
1147
|
-
events
|
|
1046
|
+
events,
|
|
1148
1047
|
cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
|
|
1149
1048
|
more: page.more,
|
|
1150
1049
|
});
|
|
@@ -1164,16 +1063,18 @@ export class DaemonConnection {
|
|
|
1164
1063
|
|
|
1165
1064
|
const decoded = decodeCursor(parsed.data.report);
|
|
1166
1065
|
|
|
1167
|
-
const
|
|
1066
|
+
const read =
|
|
1168
1067
|
decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
|
|
1169
1068
|
|
|
1170
|
-
|
|
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)) {
|
|
1171
1072
|
this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
|
|
1172
1073
|
|
|
1173
1074
|
return;
|
|
1174
1075
|
}
|
|
1175
1076
|
|
|
1176
|
-
this.sendOk(req.id, { ...view });
|
|
1077
|
+
this.sendOk(req.id, { ...read.view });
|
|
1177
1078
|
}
|
|
1178
1079
|
|
|
1179
1080
|
private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
@@ -1197,9 +1098,10 @@ export class DaemonConnection {
|
|
|
1197
1098
|
parsed.data.from,
|
|
1198
1099
|
parsed.data.text,
|
|
1199
1100
|
keyed,
|
|
1101
|
+
null,
|
|
1200
1102
|
);
|
|
1201
1103
|
|
|
1202
|
-
if (result === 'missing') {
|
|
1104
|
+
if (result === 'missing' || !ctx.isSessionVisible(sessionID)) {
|
|
1203
1105
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
1204
1106
|
|
|
1205
1107
|
return;
|
|
@@ -1240,7 +1142,7 @@ export class DaemonConnection {
|
|
|
1240
1142
|
}
|
|
1241
1143
|
|
|
1242
1144
|
const sessionID = parsed.data.session;
|
|
1243
|
-
const result = ctx.attachTap(this, sessionID);
|
|
1145
|
+
const result = ctx.attachTap(this, sessionID, null);
|
|
1244
1146
|
|
|
1245
1147
|
if (result === 'missing') {
|
|
1246
1148
|
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
@@ -1270,7 +1172,7 @@ export class DaemonConnection {
|
|
|
1270
1172
|
|
|
1271
1173
|
const view = await ctx.readMessage(messageID, parsed.data.waitMs);
|
|
1272
1174
|
|
|
1273
|
-
if (view === null) {
|
|
1175
|
+
if (view === null || !ctx.isSessionVisible(view.session)) {
|
|
1274
1176
|
this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
|
|
1275
1177
|
|
|
1276
1178
|
return;
|
|
@@ -1278,6 +1180,12 @@ export class DaemonConnection {
|
|
|
1278
1180
|
|
|
1279
1181
|
const record = view.record;
|
|
1280
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
|
+
|
|
1281
1189
|
this.sendOk(req.id, {
|
|
1282
1190
|
message: record.id,
|
|
1283
1191
|
session: view.session,
|
|
@@ -1289,7 +1197,7 @@ export class DaemonConnection {
|
|
|
1289
1197
|
...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
|
|
1290
1198
|
...(record.answer === undefined ? {} : { answer: record.answer }),
|
|
1291
1199
|
turn: record.turn ?? null,
|
|
1292
|
-
answeredWith
|
|
1200
|
+
answeredWith,
|
|
1293
1201
|
});
|
|
1294
1202
|
}
|
|
1295
1203
|
|
|
@@ -1305,7 +1213,7 @@ export class DaemonConnection {
|
|
|
1305
1213
|
const sessionID = parsed.data.session;
|
|
1306
1214
|
const messageID = parsed.data.message;
|
|
1307
1215
|
|
|
1308
|
-
const result = await ctx.ackMessage(this, sessionID, messageID);
|
|
1216
|
+
const result = await ctx.ackMessage(this, sessionID, messageID, null);
|
|
1309
1217
|
|
|
1310
1218
|
if (result === 'not_tapping') {
|
|
1311
1219
|
this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
|
|
@@ -1313,7 +1221,7 @@ export class DaemonConnection {
|
|
|
1313
1221
|
return;
|
|
1314
1222
|
}
|
|
1315
1223
|
|
|
1316
|
-
if (result === 'unknown') {
|
|
1224
|
+
if (result === 'unknown' || !ctx.isSessionVisible(sessionID)) {
|
|
1317
1225
|
this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
|
|
1318
1226
|
|
|
1319
1227
|
return;
|
|
@@ -1361,8 +1269,18 @@ export class DaemonConnection {
|
|
|
1361
1269
|
const principal = parsedHello.ok ? (parsedHello.data.principal ?? null) : null;
|
|
1362
1270
|
|
|
1363
1271
|
if (principal !== null) {
|
|
1272
|
+
const access = this.ctx.buildTargetAccess(principal);
|
|
1273
|
+
|
|
1364
1274
|
this.principal = principal;
|
|
1365
|
-
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
|
+
}
|
|
1366
1284
|
}
|
|
1367
1285
|
|
|
1368
1286
|
if (req.v !== PROTOCOL_V) {
|