@zgeoff/atc 2.28.0 → 2.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +54 -8
- package/src/client/plan-overlay-columns.ts +155 -0
- package/src/client/resolve-agent-metadata.ts +75 -0
- package/src/client/spawn-picker.ts +121 -38
- package/src/client/to-mirror-session.ts +44 -0
- package/src/client/ui.ts +109 -27
- package/src/daemon/{make-non-blocking-log.ts → create-non-blocking-log.ts} +66 -8
- package/src/daemon/daemon-connection.ts +25 -12
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/request-access-classes.ts +54 -0
- package/src/daemon/sessions.ts +209 -47
- package/src/client/format-overlay-agent-mark.ts +0 -15
|
@@ -9,7 +9,17 @@ const MAX_QUEUED_BYTES = 64 * 1024;
|
|
|
9
9
|
const writeAsync = promisify(write);
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* A log that never blocks the event loop. `log` queues one line, and
|
|
13
|
+
* `drain` resolves once every queued line is written or the given number of
|
|
14
|
+
* milliseconds has passed, whichever comes first.
|
|
15
|
+
*/
|
|
16
|
+
export interface NonBlockingLog {
|
|
17
|
+
readonly log: (line: string) => void;
|
|
18
|
+
readonly drain: (timeoutMs: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a log that writes each line to a file descriptor without ever
|
|
13
23
|
* blocking the event loop, so a reader that stops reading stalls nothing
|
|
14
24
|
* but the log. Each write goes through the asynchronous node:fs write,
|
|
15
25
|
* which Bun runs on its thread pool, one write at a time. Lines that
|
|
@@ -17,13 +27,22 @@ const writeAsync = promisify(write);
|
|
|
17
27
|
* next write after the drops starts with one `atc log dropped=N` line. A
|
|
18
28
|
* failed write drops its lines and counts them the same way, and the next
|
|
19
29
|
* line logged writes again.
|
|
30
|
+
*
|
|
31
|
+
* A write to a full pipe that nobody reads never returns, and it keeps the
|
|
32
|
+
* process alive until the process calls exit. A process that shuts down
|
|
33
|
+
* drains the log with a timeout and then exits: every queued line reaches a
|
|
34
|
+
* reader that keeps reading, and an unread pipe holds the exit back no
|
|
35
|
+
* longer than the timeout.
|
|
20
36
|
*/
|
|
21
|
-
export function
|
|
37
|
+
export function createNonBlockingLog(fd: number): NonBlockingLog {
|
|
22
38
|
let queue: string[] = [];
|
|
23
39
|
let queuedBytes = 0;
|
|
24
40
|
let dropped = 0;
|
|
25
41
|
let writing = false;
|
|
26
42
|
|
|
43
|
+
// The drains that wait for the log to have nothing left to write.
|
|
44
|
+
let idleWaiters: (() => void)[] = [];
|
|
45
|
+
|
|
27
46
|
const recordLine = (line: string): boolean => {
|
|
28
47
|
const text = `${line}\n`;
|
|
29
48
|
const bytes = Buffer.byteLength(text);
|
|
@@ -43,6 +62,8 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
|
43
62
|
if (queue.length === 0 && dropped === 0) {
|
|
44
63
|
writing = false;
|
|
45
64
|
|
|
65
|
+
releaseIdleWaiters();
|
|
66
|
+
|
|
46
67
|
return;
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -70,22 +91,59 @@ export function makeNonBlockingLog(fd: number): (line: string) => void {
|
|
|
70
91
|
rest = rest.subarray(result.bytesWritten);
|
|
71
92
|
}
|
|
72
93
|
} catch {
|
|
94
|
+
// A drain stops waiting here too: the next write would most likely
|
|
95
|
+
// fail the same way, and the lines it would carry stay counted.
|
|
73
96
|
dropped += lineCount;
|
|
74
97
|
writing = false;
|
|
75
98
|
|
|
99
|
+
releaseIdleWaiters();
|
|
100
|
+
|
|
76
101
|
return;
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
writeNext();
|
|
80
105
|
};
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
const releaseIdleWaiters = (): void => {
|
|
108
|
+
const waiters = idleWaiters;
|
|
109
|
+
|
|
110
|
+
idleWaiters = [];
|
|
86
111
|
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
for (const resolve of waiters) {
|
|
113
|
+
resolve();
|
|
89
114
|
}
|
|
90
115
|
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
log: (line) => {
|
|
119
|
+
if (!recordLine(line)) {
|
|
120
|
+
dropped++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!writing) {
|
|
124
|
+
writeNext();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
drain: async (timeoutMs) => {
|
|
128
|
+
// Lines left behind by a failed write wait for the next line logged,
|
|
129
|
+
// so a drain writes them itself.
|
|
130
|
+
if (!writing) {
|
|
131
|
+
writeNext();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!writing) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const idle = Promise.withResolvers<void>();
|
|
139
|
+
|
|
140
|
+
idleWaiters.push(idle.resolve);
|
|
141
|
+
|
|
142
|
+
const timer = setTimeout(idle.resolve, timeoutMs);
|
|
143
|
+
|
|
144
|
+
await idle.promise;
|
|
145
|
+
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
91
149
|
}
|
|
@@ -7,6 +7,7 @@ import { LineDecoder } from '../protocol/line-decoder';
|
|
|
7
7
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
8
8
|
import type { SocketWriter } from '../protocol/outbound-queue';
|
|
9
9
|
import { parseRequestParams } from '../protocol/parse-request-params';
|
|
10
|
+
import type { RequestMethod } from '../protocol/parse-request-params';
|
|
10
11
|
import {
|
|
11
12
|
MAX_CHUNK,
|
|
12
13
|
MAX_LINE,
|
|
@@ -26,6 +27,7 @@ import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
|
26
27
|
import type { DaemonContext, SpawnParams } from './daemon-context';
|
|
27
28
|
import type { TranscriptPosition } from './load-transcript-page';
|
|
28
29
|
import { parseSpawnOverrides } from './parse-spawn-overrides';
|
|
30
|
+
import { REQUEST_ACCESS_CLASSES } from './request-access-classes';
|
|
29
31
|
import { TargetAccess } from './target-access';
|
|
30
32
|
|
|
31
33
|
// The targets a request may use and the namespace of its idempotency keys.
|
|
@@ -34,15 +36,6 @@ interface RequestScope {
|
|
|
34
36
|
readonly keyNamespace: string;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
// The requests that act on the whole daemon, or on the credentials a
|
|
38
|
-
// session's host may use, which only its owner may make.
|
|
39
|
-
const OWNER_METHODS: ReadonlySet<string> = new Set([
|
|
40
|
-
'daemon.quit',
|
|
41
|
-
'fleet.restore',
|
|
42
|
-
'session.auth.revoke',
|
|
43
|
-
'session.auth.rebind',
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
39
|
// What a limited connection is sent for one event, and the sessions that
|
|
47
40
|
// event moved out of its view.
|
|
48
41
|
interface ViewUpdate {
|
|
@@ -316,7 +309,7 @@ export class DaemonConnection {
|
|
|
316
309
|
|
|
317
310
|
const scope = this.findRequestScope(req);
|
|
318
311
|
|
|
319
|
-
if (scope !== null &&
|
|
312
|
+
if (scope !== null && isOwnerOnlyMethod(req.m)) {
|
|
320
313
|
this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
|
|
321
314
|
|
|
322
315
|
return true;
|
|
@@ -379,7 +372,7 @@ export class DaemonConnection {
|
|
|
379
372
|
return;
|
|
380
373
|
}
|
|
381
374
|
|
|
382
|
-
if (
|
|
375
|
+
if (isOwnerOnlyMethod(req.m)) {
|
|
383
376
|
this.sendErr(req.id, 'unauthorized', `${req.m} is open to the daemon's owner only`);
|
|
384
377
|
|
|
385
378
|
return;
|
|
@@ -548,6 +541,12 @@ export class DaemonConnection {
|
|
|
548
541
|
private async applyRequest(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
549
542
|
await this.helloAnswered;
|
|
550
543
|
|
|
544
|
+
if (!isRequestMethod(req.m)) {
|
|
545
|
+
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
546
|
+
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
551
550
|
switch (req.m) {
|
|
552
551
|
case 'daemon.ping': {
|
|
553
552
|
this.sendOk(req.id, {});
|
|
@@ -908,7 +907,10 @@ export class DaemonConnection {
|
|
|
908
907
|
|
|
909
908
|
return;
|
|
910
909
|
}
|
|
911
|
-
|
|
910
|
+
|
|
911
|
+
// The handshake is answered before a request is admitted, so it never
|
|
912
|
+
// runs as one.
|
|
913
|
+
case 'daemon.hello': {
|
|
912
914
|
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
913
915
|
}
|
|
914
916
|
}
|
|
@@ -1779,6 +1781,17 @@ export class DaemonConnection {
|
|
|
1779
1781
|
}
|
|
1780
1782
|
}
|
|
1781
1783
|
|
|
1784
|
+
// Whether a request method is open to the daemon's owner alone. A method
|
|
1785
|
+
// the protocol does not define is not: it runs as no request and is
|
|
1786
|
+
// answered as unknown.
|
|
1787
|
+
function isOwnerOnlyMethod(method: string): boolean {
|
|
1788
|
+
return isRequestMethod(method) && REQUEST_ACCESS_CLASSES[method] === 'owner';
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function isRequestMethod(method: string): method is RequestMethod {
|
|
1792
|
+
return Object.hasOwn(REQUEST_ACCESS_CLASSES, method);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1782
1795
|
// The token of a handshake's `auth: { scheme: "bearer", token }`, or null
|
|
1783
1796
|
// when its params hold none.
|
|
1784
1797
|
function findBearerToken(p: Readonly<Record<string, unknown>> | undefined): string | null {
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -48,6 +48,8 @@ import { buildTargetAccess } from './build-target-access';
|
|
|
48
48
|
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
49
49
|
import { buildTargetList } from './build-target-list';
|
|
50
50
|
import { claimDaemonLock } from './claim-daemon-lock';
|
|
51
|
+
import { createNonBlockingLog } from './create-non-blocking-log';
|
|
52
|
+
import type { NonBlockingLog } from './create-non-blocking-log';
|
|
51
53
|
import { DaemonConnection } from './daemon-connection';
|
|
52
54
|
import type {
|
|
53
55
|
DaemonContext,
|
|
@@ -69,7 +71,6 @@ import { loadListenerTokens } from './load-listener-tokens';
|
|
|
69
71
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
70
72
|
import { makeHookRunner } from './make-hook-runner';
|
|
71
73
|
import type { HookScope } from './make-hook-runner';
|
|
72
|
-
import { makeNonBlockingLog } from './make-non-blocking-log';
|
|
73
74
|
import { materializeWorkspace } from './materialize-workspace';
|
|
74
75
|
import { mintMessageID } from './mint-message-id';
|
|
75
76
|
import { mintSessionID } from './mint-session-id';
|
|
@@ -253,6 +254,10 @@ const MAX_REFUSAL_WINDOWS = 1024;
|
|
|
253
254
|
// Where the TCP listener logs when the daemon is given no log.
|
|
254
255
|
const STDERR_FD = 2;
|
|
255
256
|
|
|
257
|
+
// How long a stopping daemon waits for the TCP listener's log to write the
|
|
258
|
+
// lines it still holds, so an unread stderr delays the exit no longer.
|
|
259
|
+
const LOG_DRAIN_TIMEOUT_MS = 1000;
|
|
260
|
+
|
|
256
261
|
// How long startup waits for a daemon that is shutting down to release the
|
|
257
262
|
// state lock before refusing to start.
|
|
258
263
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -2082,8 +2087,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2082
2087
|
// refuses the start before any client can connect, and releases what the
|
|
2083
2088
|
// daemon holds, the lock included.
|
|
2084
2089
|
let tcpListener: TCPListener | null = null;
|
|
2090
|
+
let listenerLog: NonBlockingLog | null = null;
|
|
2085
2091
|
|
|
2086
2092
|
if (opts.listen !== undefined && listenTokens !== null) {
|
|
2093
|
+
// A given log writes as it is called, so it has nothing to drain.
|
|
2094
|
+
listenerLog =
|
|
2095
|
+
opts.log === undefined
|
|
2096
|
+
? createNonBlockingLog(STDERR_FD)
|
|
2097
|
+
: { log: opts.log, drain: () => Promise.resolve() };
|
|
2098
|
+
|
|
2087
2099
|
try {
|
|
2088
2100
|
tcpListener = startTCPListener({
|
|
2089
2101
|
host: opts.listen.host,
|
|
@@ -2099,7 +2111,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2099
2111
|
return connection;
|
|
2100
2112
|
},
|
|
2101
2113
|
closeConnection: detachConnection,
|
|
2102
|
-
log:
|
|
2114
|
+
log: listenerLog.log,
|
|
2103
2115
|
now: opts.listen.now ?? Date.now,
|
|
2104
2116
|
refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
|
|
2105
2117
|
maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
|
|
@@ -2167,6 +2179,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2167
2179
|
server.stop(true);
|
|
2168
2180
|
|
|
2169
2181
|
await releaseResources();
|
|
2182
|
+
|
|
2183
|
+
// The stopped listener logs no more lines, so this writes the ones the
|
|
2184
|
+
// log still holds, waiting no longer than the timeout when nothing reads
|
|
2185
|
+
// stderr.
|
|
2186
|
+
await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
|
|
2170
2187
|
};
|
|
2171
2188
|
|
|
2172
2189
|
writeDaemonRecord(recordPath, {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RequestMethod } from '../protocol/parse-request-params';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who may make a request: `owner` is the daemon's owner alone, and
|
|
5
|
+
* `principal` is also open to a principal, which runs it through the
|
|
6
|
+
* context scoped to that principal.
|
|
7
|
+
*/
|
|
8
|
+
export type RequestAccessClass = 'owner' | 'principal';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The access class of every request method, checked when a request is
|
|
12
|
+
* admitted and before it runs. The type takes every method the protocol
|
|
13
|
+
* defines, so a method without a class fails typecheck. Owner-class
|
|
14
|
+
* methods act on the whole daemon, or on the credentials a session's host
|
|
15
|
+
* may use.
|
|
16
|
+
*/
|
|
17
|
+
export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAccessClass>> = {
|
|
18
|
+
'daemon.hello': 'principal',
|
|
19
|
+
'daemon.ping': 'principal',
|
|
20
|
+
'daemon.quit': 'owner',
|
|
21
|
+
'session.list': 'principal',
|
|
22
|
+
'dirs.list': 'principal',
|
|
23
|
+
'agents.list': 'principal',
|
|
24
|
+
'sources.list': 'principal',
|
|
25
|
+
'sources.interpret': 'principal',
|
|
26
|
+
'git.probe': 'principal',
|
|
27
|
+
'fleet.list': 'principal',
|
|
28
|
+
'fleet.restore': 'owner',
|
|
29
|
+
'session.spawn': 'principal',
|
|
30
|
+
'session.kill': 'principal',
|
|
31
|
+
'session.ack': 'principal',
|
|
32
|
+
'session.forget': 'principal',
|
|
33
|
+
'session.resumeCommand': 'principal',
|
|
34
|
+
'session.auth.revoke': 'owner',
|
|
35
|
+
'session.auth.rebind': 'owner',
|
|
36
|
+
'session.update': 'principal',
|
|
37
|
+
'session.attach': 'principal',
|
|
38
|
+
'session.detach': 'principal',
|
|
39
|
+
'session.input': 'principal',
|
|
40
|
+
'session.submit': 'principal',
|
|
41
|
+
'session.resize': 'principal',
|
|
42
|
+
'session.screen': 'principal',
|
|
43
|
+
'session.eject': 'principal',
|
|
44
|
+
'session.adopt': 'principal',
|
|
45
|
+
'permission.respond': 'principal',
|
|
46
|
+
'session.get': 'principal',
|
|
47
|
+
'session.read': 'principal',
|
|
48
|
+
'events.read': 'principal',
|
|
49
|
+
'session.message': 'principal',
|
|
50
|
+
'session.tap': 'principal',
|
|
51
|
+
'message.get': 'principal',
|
|
52
|
+
'report.get': 'principal',
|
|
53
|
+
'message.ack': 'principal',
|
|
54
|
+
};
|